mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 21:13:10 +08:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac478842ad | |||
| 4c39bbe9c2 | |||
| e1d88b071f | |||
| ea10624fb6 | |||
| 54ae9d7451 | |||
| c16701b94b | |||
| 893457cd50 | |||
| 90acfa18e4 | |||
| 1462d63a48 | |||
| cadbf87246 | |||
| 9de3f69b8f | |||
| e73a80c78c | |||
| 6c6009109f | |||
| b51d1c8284 | |||
| f286862c61 | |||
| 40f4f6546f | |||
| 3a31b438a9 | |||
| 56064d1981 | |||
| d894a3983b | |||
| 084c008102 | |||
| 4580c4db33 | |||
| 5447ecb072 | |||
| f18b4acc76 | |||
| d6109a7940 | |||
| 0463da4034 | |||
| 2d51c9ab4b | |||
| d9d8aa24e6 | |||
| 55530ff171 | |||
| 6247cf7be2 | |||
| eee910babb | |||
| 6b55a82ffe | |||
| 93dac1655f | |||
| 6ad44605c0 | |||
| d90d84906e | |||
| c3d3da9286 | |||
| 2bdf16d596 | |||
| f8ebaec4f1 | |||
| fc23f4e781 | |||
| 3ea9984aea | |||
| cc11f354f1 | |||
| 8869874b76 | |||
| 523af855ba | |||
| bba3516693 | |||
| f244adf60e | |||
| ce87b45d2d | |||
| 4031b31837 | |||
| 9b34ca5bf1 | |||
| 666b1a039d | |||
| 8f9a09d359 | |||
| 394bb8f80e | |||
| 819fdbd518 | |||
| d220b8b005 | |||
| d12bd4c71e | |||
| 4c611ef8de | |||
| eea8fee7f1 | |||
| c06656958c | |||
| 0d277b8550 | |||
| e0e4de5e82 | |||
| 39fcaf488b | |||
| 31ae0f5bc9 | |||
| 3943180898 | |||
| b1cc7c6a93 | |||
| f6557f3d62 | |||
| 9dc6cb29d9 | |||
| b86e4cb8dc | |||
| fd7fb04ed1 | |||
| 80795b8ec3 | |||
| c2b3666818 | |||
| edbf42898b | |||
| ab51cd6eae | |||
| 4619aa2bc5 | |||
| 376cada3d4 | |||
| 1a6cabf45a | |||
| 0b1e9d0943 | |||
| 18823a23d5 | |||
| 150a90f2ac | |||
| e80ee006ad | |||
| 89077c35a7 | |||
| cc87e88f03 | |||
| 7e9082f90e | |||
| 6d4ad58678 | |||
| d8ad28f5b7 | |||
| 2e27852952 | |||
| 194f941f15 | |||
| ec1f968de1 | |||
| c97173286c | |||
| cca773f07b | |||
| a4ae2acb65 | |||
| f68f3e9f4a | |||
| 6f80df2827 | |||
| 940b69fe38 | |||
| f2f7ee4107 | |||
| cf931d4760 | |||
| be708292eb | |||
| 78f2f0b25d | |||
| 6b92cbd21f |
@@ -82,9 +82,17 @@ body:
|
||||
label: 复现链接(可选)
|
||||
description: |
|
||||
请提供能复现此问题的链接。
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容(可选)
|
||||
description: |
|
||||
如果此问题是由AI辅助您发现的,请提供全部聊天记录,包括使用的模型信息。
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
|
||||
@@ -82,9 +82,17 @@ body:
|
||||
label: Reproduction Link (optional)
|
||||
description: |
|
||||
Please provide a link to a repo or page that can reproduce this issue.
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -48,9 +48,17 @@ body:
|
||||
label: 附加信息
|
||||
description: |
|
||||
相关的任何其他上下文或截图,或者你觉得有帮助的信息
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容(可选)
|
||||
description: |
|
||||
如果此请求是由AI辅助您提交的,请提供全部聊天记录,包括使用的模型信息。
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
|
||||
@@ -48,9 +48,17 @@ body:
|
||||
label: Additional Information
|
||||
description: |
|
||||
Any other context or screenshots related to this feature request, or information you find helpful.
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create or update ref
|
||||
id: create-or-update-ref
|
||||
uses: ovsds/create-or-update-ref-action@v1
|
||||
uses: ovsds/create-or-update-ref-action@1157b2991820188d5a8e6f22656775d1d171f762 # 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@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.26.4"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- 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@v1.2.5
|
||||
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
|
||||
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@openlist.team>
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
|
||||
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@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: beta builds for ${{ env.cleaned_target }}
|
||||
path: ${{ github.workspace }}/build/compress/*
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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.26.4"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- 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@v1.2.5
|
||||
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
|
||||
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@openlist.team>
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
|
||||
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@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openlist_${{ steps.short-sha.outputs.sha }}_${{ matrix.target }}
|
||||
path: build/*
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
});
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Prerelease
|
||||
uses: irongut/EditRelease@v1.2.0
|
||||
uses: irongut/EditRelease@ccf529ad26dddf9996e7dd0f24ca5da4ea507cc2 # 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@main
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # 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.26.4'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: true
|
||||
|
||||
@@ -43,15 +43,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.26'
|
||||
go-version: '1.27'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
overwrite: true
|
||||
@@ -83,15 +83,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.26.4'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # 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@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME_LITE }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Sync GitHub to Gitee
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.26.4'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # 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@v9
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
|
||||
script: |
|
||||
|
||||
+4
-2
@@ -22,6 +22,7 @@ output/
|
||||
# vendor/
|
||||
/bin/*
|
||||
*.json
|
||||
!renovate.json
|
||||
/build
|
||||
/data/
|
||||
/tmp/
|
||||
@@ -29,6 +30,7 @@ output/
|
||||
/lang/
|
||||
/daemon/
|
||||
/public/dist/*
|
||||
/!public/dist/README.md
|
||||
!/public/dist/README.md
|
||||
|
||||
.VSCodeCounter
|
||||
.VSCodeCounter
|
||||
*.syso
|
||||
@@ -1,7 +1,7 @@
|
||||
set -e
|
||||
appName="openlist"
|
||||
builtAt="$(date +'%F %T %z')"
|
||||
gitAuthor="The OpenList Projects Contributors <noreply@openlist.team>"
|
||||
gitAuthor="The OpenList Projects Contributors <noreply@oplist.org>"
|
||||
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/rolling\"")
|
||||
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_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.3"
|
||||
freebsd_version="14.3"
|
||||
echo "Failed to get FreeBSD version, falling back to 14.4"
|
||||
freebsd_version="14.4"
|
||||
fi
|
||||
|
||||
echo "Using FreeBSD version: $freebsd_version"
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ type options struct {
|
||||
|
||||
pwd string //de/encrypt password
|
||||
salt string
|
||||
filenameEncryption string //reference drivers\crypt\meta.go Addtion
|
||||
filenameEncryption string //reference drivers\crypt\meta.go Addition
|
||||
dirnameEncryption string
|
||||
filenameEncode string
|
||||
suffix string
|
||||
|
||||
+17
-7
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -31,14 +32,23 @@ func start() {
|
||||
return
|
||||
}
|
||||
}
|
||||
args := os.Args
|
||||
args[1] = "server"
|
||||
args = append(args, "--force-bin-dir")
|
||||
cmd := &exec.Cmd{
|
||||
Path: args[0],
|
||||
Args: args,
|
||||
Env: os.Environ(),
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal("failed to resolve executable path: ", err)
|
||||
}
|
||||
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)
|
||||
|
||||
+4
-2
@@ -337,9 +337,11 @@ 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++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if utils.IsCanceled(ctx) {
|
||||
err = ctx.Err()
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if ossToken, err = d.client.GetOSSToken(); err != nil { // 到时重新获取ossToken
|
||||
errCh <- errors.Wrap(err, "刷新token时出现错误")
|
||||
|
||||
+61
-16
@@ -3,6 +3,7 @@ package _115_open
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
@@ -70,6 +71,19 @@ 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 {
|
||||
@@ -80,7 +94,32 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
|
||||
return err
|
||||
}
|
||||
|
||||
imur, err := bucket.InitiateMultipartUpload(initResp.Object, oss.Sequential())
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,13 +148,17 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
|
||||
return err
|
||||
}
|
||||
err = retry.Do(func() error {
|
||||
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
|
||||
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
|
||||
})
|
||||
},
|
||||
retry.Context(ctx),
|
||||
retry.Attempts(3),
|
||||
@@ -134,14 +177,16 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
|
||||
up(float64(offset) * 100 / float64(fileSize))
|
||||
}
|
||||
|
||||
// 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),
|
||||
)
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+5
-10
@@ -45,16 +45,11 @@ func (d *Yun139) GetAddition() driver.Additional {
|
||||
|
||||
func (d *Yun139) Init(ctx context.Context) error {
|
||||
if d.ref == nil {
|
||||
if len(d.Authorization) == 0 && !d.isShare() {
|
||||
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")
|
||||
// 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 d.Authorization != "" {
|
||||
|
||||
+5
-4
@@ -7,10 +7,11 @@ import (
|
||||
|
||||
type Addition struct {
|
||||
//Account string `json:"account" required:"true"`
|
||||
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."`
|
||||
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."`
|
||||
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."`
|
||||
|
||||
+401
-77
@@ -6,9 +6,12 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
crypto_rand "crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -27,6 +30,7 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||
cookiepkg "github.com/OpenListTeam/OpenList/v4/pkg/cookie"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
"github.com/avast/retry-go"
|
||||
@@ -36,8 +40,22 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d" // 第一层 AES 解密密钥
|
||||
KEY_HEX_2 = "7150714477323633586746674c337538" // 第二层 AES 解密密钥
|
||||
KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d" // 第一层 AES 解密密钥
|
||||
KEY_HEX_2 = "7150714477323633586746674c337538" // 第二层 AES 解密密钥
|
||||
mailPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnsOHTFtwW5rq/8gGhPlM5Z3RPdeN/d+FYIHb5JmcfBOCozXuT8c+0anvxtkjzghixwNlnmBuhN8OYfS789YuH/qReQbHC7OdlisLildNWPHRNUYcPa0W3lXSG3+81CXK7FDXPvXo5ubw2GqVbIsccMarI1dyfXdi4ITiCXvmM9wYBdUs9yXtoorhlpyYUI2GV8HNsQjWK9P5QZHT3ox5Qy+mjRmvv6RUFJLPOMkOS/pGZ0DwC1ypFZBxstW0/ftVupdOmGWvW7J2/e3dq3A/UvIkC4YUY/diL1wighJx1G9MiRROISjNMvNyUDSTqPJy516+l3sgHEbc067QIJx2NQIDAQAB"
|
||||
)
|
||||
|
||||
var (
|
||||
mailPasswordURL = "https://mail.10086.cn/Login/Login.ashx"
|
||||
mailSMSURL = "https://mail.10086.cn/s"
|
||||
)
|
||||
|
||||
type credentialState int
|
||||
|
||||
const (
|
||||
credentialStateAuthorization credentialState = iota
|
||||
credentialStateFullLogin
|
||||
credentialStateCookiesOnly
|
||||
)
|
||||
|
||||
// do others that not defined in Driver interface
|
||||
@@ -86,29 +104,28 @@ func (d *Yun139) refreshToken() error {
|
||||
}
|
||||
decode, err := base64.StdEncoding.DecodeString(d.Authorization)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authorization decode failed: %s", err)
|
||||
return d.loginAfterAuthorizationFailure(fmt.Errorf("authorization decode failed: %w", err))
|
||||
}
|
||||
decodeStr := string(decode)
|
||||
splits := strings.Split(decodeStr, ":")
|
||||
if len(splits) < 3 {
|
||||
return fmt.Errorf("authorization is invalid, splits < 3")
|
||||
return d.loginAfterAuthorizationFailure(errors.New("authorization is invalid, splits < 3"))
|
||||
}
|
||||
d.Account = splits[1]
|
||||
strs := strings.Split(splits[2], "|")
|
||||
if len(strs) < 4 {
|
||||
return fmt.Errorf("authorization is invalid, strs < 4")
|
||||
return d.loginAfterAuthorizationFailure(errors.New("authorization is invalid, strs < 4"))
|
||||
}
|
||||
expiration, err := strconv.ParseInt(strs[3], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authorization is invalid")
|
||||
return d.loginAfterAuthorizationFailure(errors.New("authorization expiration is invalid"))
|
||||
}
|
||||
expiration -= time.Now().UnixMilli()
|
||||
if expiration > 1000*60*60*24*15 {
|
||||
// Authorization有效期大于15天无需刷新
|
||||
return nil
|
||||
}
|
||||
if expiration < 0 {
|
||||
return fmt.Errorf("authorization has expired")
|
||||
return d.loginAfterAuthorizationFailure(errors.New("authorization has expired"))
|
||||
}
|
||||
|
||||
url := "https://aas.caiyun.feixin.10086.cn:443/tellin/authTokenRefresh.do"
|
||||
@@ -120,13 +137,7 @@ func (d *Yun139) refreshToken() error {
|
||||
SetResult(&resp).
|
||||
Post(url)
|
||||
if err != nil || resp.Return != "0" {
|
||||
log.Warnf("139yun: failed to refresh token with old token: %v, desc: %s. trying to login with password.", err, resp.Desc)
|
||||
newAuth, loginErr := d.loginWithPassword()
|
||||
log.Debugf("newAuth: Ok: %s", newAuth)
|
||||
if loginErr != nil {
|
||||
return fmt.Errorf("failed to login with password after refresh failed: %w", loginErr)
|
||||
}
|
||||
return nil
|
||||
return d.loginAfterAuthorizationFailure(fmt.Errorf("token refresh failed: %v, desc: %s", err, resp.Desc))
|
||||
}
|
||||
|
||||
d.Authorization = base64.StdEncoding.EncodeToString([]byte(splits[0] + ":" + splits[1] + ":" + resp.Token))
|
||||
@@ -134,6 +145,18 @@ func (d *Yun139) refreshToken() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginAfterAuthorizationFailure deliberately skips cookie fast login. Mail
|
||||
// cookies are only reused as device context for the password login request.
|
||||
func (d *Yun139) loginAfterAuthorizationFailure(cause error) error {
|
||||
log.Warnf("139yun: %v; trying password login.", cause)
|
||||
newAuth, err := d.loginWithPassword()
|
||||
log.Debugf("139yun: password fallback generated authorization: %t", newAuth != "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v; password login failed: %w", cause, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Yun139) request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
req := base.RestyClient.R()
|
||||
randStr := random.String(16)
|
||||
@@ -507,7 +530,7 @@ func (d *Yun139) shareHeaders() map[string]string {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"X-Deviceinfo": "||9|12.27.0|firefox|140.0|||linux unknow|1920X526|zh-CN|||",
|
||||
"X-Deviceinfo": "||9|12.27.0|firefox|140.0|||linux unknown|1920X526|zh-CN|||",
|
||||
"hcy-cool-flag": "1",
|
||||
"CMS-DEVICE": "default",
|
||||
"x-m4c-caller": "PC",
|
||||
@@ -1116,15 +1139,212 @@ func (d *Yun139) getDiskQuotaDetail(ctx context.Context) (*DiskQuotaDetail, erro
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type mailLoginResponse struct {
|
||||
Code string `json:"code"`
|
||||
Summary string `json:"summary"`
|
||||
Var struct {
|
||||
LoginSuccessURL string `json:"loginSuccessUrl"`
|
||||
} `json:"var"`
|
||||
}
|
||||
|
||||
func parseMailLoginResponse(body []byte) mailLoginResponse {
|
||||
var response mailLoginResponse
|
||||
if utils.Json.Unmarshal(body, &response) == nil && (response.Code != "" || response.Summary != "" || response.Var.LoginSuccessURL != "") {
|
||||
return response
|
||||
}
|
||||
if match := regexp.MustCompile(`['"]?code['"]?\s*:\s*['"]([^'"]+)`).FindSubmatch(body); len(match) == 2 {
|
||||
response.Code = string(match[1])
|
||||
}
|
||||
if match := regexp.MustCompile(`['"]?summary['"]?\s*:\s*['"]([^'"]+)`).FindSubmatch(body); len(match) == 2 {
|
||||
response.Summary = string(match[1])
|
||||
}
|
||||
if match := regexp.MustCompile(`['"]?loginSuccessUrl['"]?\s*:\s*['"]([^'"]+)`).FindSubmatch(body); len(match) == 2 {
|
||||
response.Var.LoginSuccessURL = string(match[1])
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func mergeMailCookieHeader(existing string, responseCookies []*http.Cookie) string {
|
||||
cookies := cookiepkg.Parse(existing)
|
||||
for _, responseCookie := range responseCookies {
|
||||
if responseCookie != nil && responseCookie.Name != "" {
|
||||
cookies = cookiepkg.SetCookie(cookies, responseCookie.Name, responseCookie.Value)
|
||||
}
|
||||
}
|
||||
return cookiepkg.ToString(cookies)
|
||||
}
|
||||
|
||||
func mailRiskCode(location string) string {
|
||||
parsed, err := url.Parse(location)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Query().Get("ec")
|
||||
}
|
||||
|
||||
func smsSceneForRisk(riskCode string) (int, bool) {
|
||||
switch riskCode {
|
||||
case "PML401010062":
|
||||
return 2, true
|
||||
case "MW0016":
|
||||
return 4, true
|
||||
case "S025", "S035":
|
||||
return 1, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func encryptMailLoginName(account string) (string, error) {
|
||||
der, err := base64.StdEncoding.DecodeString(mailPublicKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode mail public key: %w", err)
|
||||
}
|
||||
parsed, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse mail public key: %w", err)
|
||||
}
|
||||
publicKey, ok := parsed.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return "", errors.New("mail public key is not RSA")
|
||||
}
|
||||
encrypted, err := rsa.EncryptPKCS1v15(crypto_rand.Reader, publicKey, []byte(account))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt mail login name: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
func mailXMLHeaders(cookie string) map[string]string {
|
||||
return map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"Accept-Encoding": "gzip",
|
||||
"User-Agent": "okhttp/4.12.0",
|
||||
}
|
||||
}
|
||||
|
||||
func new139RestyClient() *resty.Client {
|
||||
if base.RestyClient != nil {
|
||||
return base.RestyClient.Clone()
|
||||
}
|
||||
return base.NewRestyClient()
|
||||
}
|
||||
|
||||
func (d *Yun139) sendSMSVerificationCode(riskCode string) error {
|
||||
scene, ok := smsSceneForRisk(riskCode)
|
||||
if !ok {
|
||||
return fmt.Errorf("139 Mail risk control does not support SMS verification: %s", riskCode)
|
||||
}
|
||||
loginName, err := encryptMailLoginName(d.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.Join([]string{
|
||||
"<object>",
|
||||
mailXMLField("loginName", loginName),
|
||||
mailXMLField("fv", "4"),
|
||||
mailXMLField("clientId", "1003"),
|
||||
mailXMLField("eMode", "1"),
|
||||
mailXMLField("loginFailureUrl", ""),
|
||||
mailXMLField("loginSuccessUrl", ""),
|
||||
mailXMLField("verifyCode", ""),
|
||||
mailXMLField("version", "1.0"),
|
||||
mailXMLField("scene", strconv.Itoa(scene)),
|
||||
"</object>",
|
||||
}, "")
|
||||
res, err := new139RestyClient().SetRetryCount(0).R().
|
||||
SetHeaders(mailXMLHeaders(d.MailCookies)).
|
||||
SetBody(body).
|
||||
Post(mailSMSURL + "?func=" + url.QueryEscape("login:sendSmsCodeByScene") + "&cguid=" + strconv.FormatInt(time.Now().UnixMilli(), 10))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send 139 Mail SMS verification code: %w", err)
|
||||
}
|
||||
d.MailCookies = mergeMailCookieHeader(d.MailCookies, res.Cookies())
|
||||
response := parseMailLoginResponse(res.Body())
|
||||
if response.Code == "S_OK" {
|
||||
return nil
|
||||
}
|
||||
switch response.Code {
|
||||
case "PML401010021", "PML401010022":
|
||||
return fmt.Errorf("139 Mail requires picture verification before SMS can be sent: %s", response.Code)
|
||||
case "PML404010001":
|
||||
return errors.New("139 Mail SMS verification code was requested too frequently")
|
||||
case "PML401010002":
|
||||
return errors.New("139 Mail rejected the SMS verification parameters")
|
||||
default:
|
||||
return fmt.Errorf("send 139 Mail SMS verification code failed: code=%s summary=%s", response.Code, response.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Yun139) verifySMSCode(riskCode string) (string, error) {
|
||||
if _, ok := smsSceneForRisk(riskCode); !ok {
|
||||
return "", fmt.Errorf("139 Mail risk control does not support SMS verification: %s", riskCode)
|
||||
}
|
||||
loginName, err := encryptMailLoginName(d.Username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pwdType := ""
|
||||
if riskCode == "MW0016" {
|
||||
pwdType = mailXMLField("pwdType", "1")
|
||||
}
|
||||
body := strings.Join([]string{
|
||||
"<object>",
|
||||
mailXMLField("clientId", "1003"),
|
||||
mailXMLField("version", "4"),
|
||||
mailXMLField("loginType", "0"),
|
||||
mailXMLField("authType", "2"),
|
||||
mailXMLField("loginName", loginName),
|
||||
mailXMLField("eMode", "1"),
|
||||
mailXMLField("loginPassword", sha1Hash("fetion.com.cn:"+d.SmsCode)),
|
||||
mailXMLField("createAutoLoginSecretKey", "1"),
|
||||
mailXMLField("verifyCode", ""),
|
||||
mailXMLField("verifyAgentId", ""),
|
||||
mailXMLField("reqFrom", "3"),
|
||||
mailXMLField("needWCookie", "1"),
|
||||
pwdType,
|
||||
"</object>",
|
||||
}, "")
|
||||
res, err := new139RestyClient().SetRetryCount(0).R().
|
||||
SetHeaders(mailXMLHeaders(d.MailCookies)).
|
||||
SetBody(body).
|
||||
Post(mailSMSURL + "?func=" + url.QueryEscape("/login/inlogin.action") + "&cguid=" + strconv.FormatInt(time.Now().UnixMilli(), 10))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("verify 139 Mail SMS code: %w", err)
|
||||
}
|
||||
d.MailCookies = mergeMailCookieHeader(d.MailCookies, res.Cookies())
|
||||
response := parseMailLoginResponse(res.Body())
|
||||
if response.Code != "S_OK" {
|
||||
return "", fmt.Errorf("verify 139 Mail SMS code failed: code=%s summary=%s", response.Code, response.Summary)
|
||||
}
|
||||
sid := ""
|
||||
if response.Var.LoginSuccessURL != "" {
|
||||
if successURL, parseErr := url.Parse(response.Var.LoginSuccessURL); parseErr == nil {
|
||||
sid = successURL.Query().Get("sid")
|
||||
}
|
||||
}
|
||||
if sid == "" {
|
||||
for _, cookie := range cookiepkg.Parse(d.MailCookies) {
|
||||
if cookie.Name == "Os_SSo_Sid" || cookie.Name == "sid" {
|
||||
sid = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if sid == "" {
|
||||
return "", errors.New("139 Mail SMS verification succeeded but did not return sid")
|
||||
}
|
||||
d.SmsCode = ""
|
||||
return sid, nil
|
||||
}
|
||||
|
||||
func (d *Yun139) step1_password_login() (string, error) {
|
||||
log.Debugf("--- 执行步骤 1: 登录 API ---")
|
||||
loginURL := "https://mail.10086.cn/Login/Login.ashx"
|
||||
loginURL := mailPasswordURL
|
||||
|
||||
// 密码 SHA1 哈希
|
||||
hashedPassword := sha1Hash(fmt.Sprintf("fetion.com.cn:%s", d.Password))
|
||||
log.Debugf("DEBUG: 原始密码: %s", d.Password)
|
||||
log.Debugf("DEBUG: SHA1 输入: fetion.com.cn:%s", d.Password)
|
||||
log.Debugf("DEBUG: 生成的 Password 哈希: %s", hashedPassword)
|
||||
|
||||
cguid := strconv.FormatInt(time.Now().UnixMilli(), 10) // 随机生成 cguid
|
||||
|
||||
@@ -1160,79 +1380,56 @@ func (d *Yun139) step1_password_login() (string, error) {
|
||||
loginData.Set("authType", "2")
|
||||
|
||||
log.Debugf("DEBUG: 登录请求 URL: %s", loginURL)
|
||||
log.Debugf("DEBUG: 登录请求 Headers: %+v", loginHeaders)
|
||||
log.Debugf("DEBUG: 登录请求 Body: %s", loginData.Encode())
|
||||
log.Debugf("DEBUG: 登录请求已准备")
|
||||
|
||||
// 设置客户端不跟随重定向
|
||||
client := base.RestyClient.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
res, err := client.R().
|
||||
res, err := new139RestyClient().
|
||||
SetRetryCount(0).
|
||||
SetRedirectPolicy(resty.RedirectPolicyFunc(func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
})).R().
|
||||
SetHeaders(loginHeaders).
|
||||
SetFormDataFromValues(loginData).
|
||||
Post(loginURL)
|
||||
|
||||
if err != nil {
|
||||
// 如果是重定向错误,则不作为失败处理,因为我们禁止了自动重定向
|
||||
if res != nil && res.StatusCode() >= 300 && res.StatusCode() < 400 {
|
||||
log.Debugf("DEBUG: 登录响应 Status Code: %d (Redirect)", res.StatusCode())
|
||||
} else {
|
||||
return "", fmt.Errorf("step1 login request failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode())
|
||||
return "", fmt.Errorf("step1 login request failed: %w", err)
|
||||
}
|
||||
// 恢复客户端的默认重定向策略,以免影响后续请求
|
||||
base.RestyClient.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
|
||||
log.Debugf("DEBUG: 登录响应 Headers: %+v", res.Header())
|
||||
log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode())
|
||||
log.Debugf("DEBUG: 登录响应 Location present: %t", res.Header().Get("Location") != "")
|
||||
|
||||
var sid, extractedCguid string
|
||||
d.MailCookies = mergeMailCookieHeader(d.MailCookies, res.Cookies())
|
||||
|
||||
// 从 Location 头部提取 sid 和 cguid
|
||||
sid := ""
|
||||
locationHeader := res.Header().Get("Location")
|
||||
if locationHeader != "" {
|
||||
sidMatch := regexp.MustCompile(`sid=([^&]+)`).FindStringSubmatch(locationHeader)
|
||||
cguidMatch := regexp.MustCompile(`cguid=([^&]+)`).FindStringSubmatch(locationHeader)
|
||||
if len(sidMatch) > 1 {
|
||||
sid = sidMatch[1]
|
||||
log.Debugf("DEBUG: 从 Location 提取到 sid: %s", sid)
|
||||
if riskCode := mailRiskCode(locationHeader); riskCode != "" {
|
||||
if _, ok := smsSceneForRisk(riskCode); !ok {
|
||||
return "", fmt.Errorf("139 Mail risk control triggered: %s", riskCode)
|
||||
}
|
||||
if strings.TrimSpace(d.SmsCode) == "" {
|
||||
if sendErr := d.sendSMSVerificationCode(riskCode); sendErr != nil {
|
||||
return "", sendErr
|
||||
}
|
||||
op.MustSaveDriverStorage(d)
|
||||
return "", errors.New("139 Mail SMS verification code sent; fill sms_code and save the storage again")
|
||||
}
|
||||
return d.verifySMSCode(riskCode)
|
||||
}
|
||||
if len(cguidMatch) > 1 {
|
||||
extractedCguid = cguidMatch[1]
|
||||
log.Debugf("DEBUG: 从 Location 提取到 cguid: %s", extractedCguid)
|
||||
if redirectURL, parseErr := url.Parse(locationHeader); parseErr == nil {
|
||||
sid = redirectURL.Query().Get("sid")
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 Location 中没有,尝试从 Set-Cookie 中提取
|
||||
if sid == "" || extractedCguid == "" {
|
||||
setCookieHeaders := res.Header().Values("Set-Cookie")
|
||||
for _, cookieStr := range setCookieHeaders {
|
||||
ssoSidMatch := regexp.MustCompile(`Os_SSo_Sid=([^;]+)`).FindStringSubmatch(cookieStr)
|
||||
cookieCguidMatch := regexp.MustCompile(`cguid=([^;]+)`).FindStringSubmatch(cookieStr)
|
||||
if len(ssoSidMatch) > 1 && sid == "" {
|
||||
sid = ssoSidMatch[1]
|
||||
log.Debugf("DEBUG: 从 Set-Cookie 提取到 sid: %s", sid)
|
||||
}
|
||||
if len(cookieCguidMatch) > 1 && extractedCguid == "" {
|
||||
extractedCguid = cookieCguidMatch[1]
|
||||
log.Debugf("DEBUG: 从 Set-Cookie 提取到 cguid: %s", extractedCguid)
|
||||
if sid == "" {
|
||||
for _, cookie := range res.Cookies() {
|
||||
if cookie.Name == "Os_SSo_Sid" || cookie.Name == "sid" {
|
||||
sid = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sid == "" || extractedCguid == "" {
|
||||
return "", errors.New("failed to extract sid or cguid from login response")
|
||||
if sid == "" {
|
||||
return "", errors.New("failed to extract sid from login response")
|
||||
}
|
||||
|
||||
// 提取并记录 cookies
|
||||
loginUrlObj, _ := url.Parse(loginURL)
|
||||
cookies := base.RestyClient.GetClient().Jar.Cookies(loginUrlObj)
|
||||
var cookieStrings []string
|
||||
for _, cookie := range cookies {
|
||||
cookieStrings = append(cookieStrings, cookie.Name+"="+cookie.Value)
|
||||
}
|
||||
cookieStr := strings.Join(cookieStrings, "; ")
|
||||
log.Debugf("DEBUG: 提取到的 Cookies: %s", cookieStr)
|
||||
d.MailCookies = cookieStr
|
||||
|
||||
return sid, nil
|
||||
}
|
||||
|
||||
@@ -1287,6 +1484,16 @@ func (d *Yun139) step2_get_single_token(sid string) (string, error) {
|
||||
return dycpwd, nil
|
||||
}
|
||||
|
||||
func mailXMLField(name, value string) string {
|
||||
return `<string name="` + escapeXML(name) + `">` + escapeXML(value) + `</string>`
|
||||
}
|
||||
|
||||
func escapeXML(value string) string {
|
||||
var escaped bytes.Buffer
|
||||
_ = xml.EscapeText(&escaped, []byte(value))
|
||||
return escaped.String()
|
||||
}
|
||||
|
||||
// --- 辅助函数:加密/解密 ---
|
||||
|
||||
// sha1Hash 计算 SHA1 哈希值,返回十六进制字符串。
|
||||
@@ -1582,9 +1789,126 @@ func (d *Yun139) step3_third_party_login(dycpwd string) (string, error) {
|
||||
return newAuthorization, nil
|
||||
}
|
||||
|
||||
func extractFastLoginCookies(mailCookies string) (sid string, rmkey string) {
|
||||
for _, c := range cookiepkg.Parse(mailCookies) {
|
||||
switch c.Name {
|
||||
case "Os_SSo_Sid":
|
||||
sid = c.Value
|
||||
case "RMKEY":
|
||||
rmkey = c.Value
|
||||
}
|
||||
if sid != "" && rmkey != "" {
|
||||
return sid, rmkey
|
||||
}
|
||||
}
|
||||
return sid, rmkey
|
||||
}
|
||||
|
||||
func hasCookiePair(raw string) bool {
|
||||
for _, part := range strings.Split(raw, ";") {
|
||||
name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if ok && strings.TrimSpace(name) != "" && value != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Yun139) tryFastLoginWithCookies() bool {
|
||||
sid, rmkey := extractFastLoginCookies(d.MailCookies)
|
||||
if sid == "" || rmkey == "" {
|
||||
log.Warnf("139yun: fast login skipped, required cookies missing: Os_SSo_Sid=%t RMKEY=%t", sid != "", rmkey != "")
|
||||
return false
|
||||
}
|
||||
|
||||
log.Infof("139yun: attempting fast login using existing SID/Cookies (Step 2).")
|
||||
token, err := d.step2_get_single_token(sid)
|
||||
if err != nil || token == "" {
|
||||
log.Warnf("139yun: fast login Step 2 failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Infof("139yun: Step 2 success. Proceeding to Step 3.")
|
||||
auth, err := d.step3_third_party_login(token)
|
||||
if err != nil {
|
||||
log.Warnf("139yun: fast login Step 3 failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
d.Authorization = auth
|
||||
op.MustSaveDriverStorage(d)
|
||||
log.Infof("139yun: fast login success (Step 2 -> Step 3).")
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *Yun139) validateAndInitCredentials() error {
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch state {
|
||||
case credentialStateAuthorization:
|
||||
// Authorization is refreshed by Init immediately after this helper returns.
|
||||
log.Debugf("139yun: Authorization exists, skipping initialization login.")
|
||||
return nil
|
||||
case credentialStateFullLogin, credentialStateCookiesOnly:
|
||||
log.Infof("139yun: Authorization missing, attempting login...")
|
||||
if d.MailCookies != "" && d.tryFastLoginWithCookies() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if state == credentialStateCookiesOnly {
|
||||
return fmt.Errorf("fast login with cookies failed, and cannot fallback to password login (missing username/password)")
|
||||
}
|
||||
|
||||
log.Infof("139yun: fast login failed or not possible, performing full password login (Step 1).")
|
||||
_, err := d.loginWithPassword()
|
||||
if err != nil {
|
||||
return fmt.Errorf("login with password failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported credential state: %d", state)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Yun139) credentialState() (credentialState, error) {
|
||||
d.Authorization = strings.TrimSpace(d.Authorization)
|
||||
d.Username = strings.TrimSpace(d.Username)
|
||||
d.MailCookies = strings.TrimSpace(d.MailCookies)
|
||||
|
||||
if d.Authorization != "" {
|
||||
if strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
|
||||
return 0, fmt.Errorf("authorization should not include Basic prefix")
|
||||
}
|
||||
return credentialStateAuthorization, nil
|
||||
}
|
||||
|
||||
if d.MailCookies != "" && !hasCookiePair(d.MailCookies) {
|
||||
return 0, fmt.Errorf("MailCookies format is invalid, please check your configuration")
|
||||
}
|
||||
|
||||
hasUsername := d.Username != ""
|
||||
hasPassword := strings.TrimSpace(d.Password) != ""
|
||||
|
||||
if hasUsername != hasPassword {
|
||||
return 0, fmt.Errorf("username and password must be provided together")
|
||||
}
|
||||
if hasUsername {
|
||||
return credentialStateFullLogin, nil
|
||||
}
|
||||
|
||||
if d.MailCookies != "" {
|
||||
return credentialStateCookiesOnly, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("authorization is empty and credentials are not provided")
|
||||
}
|
||||
|
||||
func (d *Yun139) loginWithPassword() (string, error) {
|
||||
if d.Username == "" || d.Password == "" || d.MailCookies == "" {
|
||||
return "", errors.New("username, password or mail_cookies is empty")
|
||||
if d.Username == "" || d.Password == "" {
|
||||
return "", errors.New("username or password is empty")
|
||||
}
|
||||
|
||||
passId, err := d.step1_password_login()
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -5,12 +5,15 @@ 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 {
|
||||
@@ -57,7 +60,13 @@ func timestamp() int64 {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
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) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
@@ -72,10 +81,19 @@ 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
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -184,6 +184,7 @@ 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
|
||||
@@ -195,7 +196,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
"mediaAttr": "0",
|
||||
"iconOption": "5",
|
||||
"pageNum": fmt.Sprint(pageNum),
|
||||
"pageSize": "130",
|
||||
"pageSize": fmt.Sprint(pageSize),
|
||||
})
|
||||
if isFamily {
|
||||
r.SetQueryParams(map[string]string{
|
||||
@@ -218,13 +219,22 @@ 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 < len(resp.FileListAO.FolderList); i++ {
|
||||
for i := 0; i < FolderCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FolderList[i])
|
||||
}
|
||||
for i := 0; i < len(resp.FileListAO.FileList); i++ {
|
||||
for i := 0; i < FileCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FileList[i])
|
||||
}
|
||||
|
||||
// 文件数量小于设定数量时跳出
|
||||
if PageCount < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
+20
-3
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
)
|
||||
|
||||
@@ -101,7 +103,13 @@ func MustParseTime(str string) *time.Time {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
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) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
@@ -116,10 +124,19 @@ 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
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,16 @@ import (
|
||||
|
||||
type Addition struct {
|
||||
LoginType string `json:"login_type" type:"select" options:"password,qrcode" default:"password" required:"true"`
|
||||
Username string `json:"username" required:"true"`
|
||||
Password string `json:"password" required:"true"`
|
||||
Username string `json:"username" help:"Not needed when an access token or refresh token is provided"`
|
||||
Password string `json:"password" help:"Not needed when an access token or refresh token is provided"`
|
||||
VCode string `json:"validate_code"`
|
||||
SmsCode string `json:"sms_code" help:"SMS code for the second device verification, fill it in and save again when login asks for it"`
|
||||
AccessToken string `json:"access_token" required:"false"`
|
||||
RefreshToken string `json:"refresh_token" help:"To switch accounts, please clear this field"`
|
||||
DeviceID string `json:"device_id" help:"DEVICEID cookie issued after the second device verification, keep it to avoid verifying again"`
|
||||
ClientSn string `json:"client_sn" help:"Device serial number captured from the official client, leave it empty if you do not have one"`
|
||||
JgOpenId string `json:"jg_open_id" help:"Optional push id reported by the official client"`
|
||||
UserFinger string `json:"user_finger" help:"Device fingerprint sent with login requests, generated and kept automatically when empty"`
|
||||
driver.RootID
|
||||
OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"`
|
||||
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
|
||||
|
||||
@@ -72,6 +72,8 @@ type BaseLoginParam struct {
|
||||
// 请求头参数
|
||||
Lt string
|
||||
ReqId string
|
||||
// logbox页面地址,作为后续请求的Referer,缺失会被判定为陌生设备
|
||||
Referer string
|
||||
|
||||
// 表单参数
|
||||
ParamId string
|
||||
@@ -97,10 +99,20 @@ type LoginParam struct {
|
||||
|
||||
// rsa密钥
|
||||
jRsaKey string
|
||||
// 加密字段的前缀,服务端下发(如 {NRP})
|
||||
rsaPrefix string
|
||||
|
||||
// 设备二次校验时服务端返回的加密手机号
|
||||
SecondAuthMobile string
|
||||
|
||||
BaseLoginParam
|
||||
}
|
||||
|
||||
// encryptSecret 用登陆时拿到的公钥加密敏感值,格式与userName/epd一致
|
||||
func (p *LoginParam) encryptSecret(value string) string {
|
||||
return p.rsaPrefix + RsaEncrypt(p.jRsaKey, value)
|
||||
}
|
||||
|
||||
// 登陆加密相关
|
||||
type EncryptConfResp struct {
|
||||
Result int `json:"result"`
|
||||
@@ -116,6 +128,35 @@ type LoginResp struct {
|
||||
Msg string `json:"msg"`
|
||||
Result int `json:"result"`
|
||||
ToUrl string `json:"toUrl"`
|
||||
// 设备二次校验时返回的加密手机号
|
||||
Mobile string `json:"mobile"`
|
||||
}
|
||||
|
||||
// 登陆页配置,新版登陆页的paramId由该接口下发
|
||||
// 该接口的result可能是数字也可能是字符串
|
||||
type AppConfResp struct {
|
||||
Result any `json:"result"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
ParamId string `json:"paramId"`
|
||||
AccountType string `json:"accountType"`
|
||||
ReturnUrl string `json:"returnUrl"`
|
||||
MailSuffix string `json:"mailSuffix"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (r *AppConfResp) Succeeded() bool {
|
||||
switch v := r.Result.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return v == "0" || v == ""
|
||||
case float64:
|
||||
return v == 0
|
||||
case int:
|
||||
return v == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 刷新session返回
|
||||
@@ -149,6 +190,27 @@ type AppSessionResp struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
// 刷新token返回,失败时以HTTP 200返回result/msg,需要单独判断
|
||||
type RefreshTokenResp struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
|
||||
Result int `json:"result"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
func (r *RefreshTokenResp) HasError() bool {
|
||||
return r.Result != 0 || r.AccessToken == ""
|
||||
}
|
||||
|
||||
func (r *RefreshTokenResp) Error() string {
|
||||
if r.Msg != "" {
|
||||
return fmt.Sprintf("refresh token failed, result: %d, msg: %s", r.Result, r.Msg)
|
||||
}
|
||||
return fmt.Sprintf("refresh token failed, result: %d", r.Result)
|
||||
}
|
||||
|
||||
// 家庭云账户
|
||||
type FamilyInfoListResp struct {
|
||||
FamilyInfoResp []FamilyInfoResp `json:"familyInfoResp"`
|
||||
@@ -191,6 +253,10 @@ 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)
|
||||
}
|
||||
@@ -207,6 +273,8 @@ 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"`
|
||||
@@ -238,6 +306,8 @@ 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"`
|
||||
|
||||
+368
-80
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/errgroup"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/avast/retry-go"
|
||||
@@ -41,9 +42,13 @@ import (
|
||||
|
||||
const (
|
||||
ACCOUNT_TYPE = "02"
|
||||
APP_ID = "8025431004"
|
||||
CLIENT_TYPE = "10020"
|
||||
VERSION = "6.2"
|
||||
// 官方 PC 端(cloud.189.cn 网页/客户端)使用的 appId,
|
||||
// 登录、生成二维码、换取 session 必须全程使用同一个 appId
|
||||
APP_ID = "9317140619"
|
||||
CLIENT_TYPE = "10020"
|
||||
// 扫码状态轮询使用的 clientType,与密码登录的 10020 不同
|
||||
QR_CLIENT_TYPE = "1"
|
||||
VERSION = "7.2.4.0"
|
||||
|
||||
WEB_URL = "https://cloud.189.cn"
|
||||
AUTH_URL = "https://open.e.189.cn"
|
||||
@@ -57,8 +62,18 @@ const (
|
||||
|
||||
CHANNEL_ID = "web_cloud.189.cn"
|
||||
|
||||
// 服务端通过短信二次校验后下发的设备标识,复用它可以避免再次触发校验
|
||||
DEVICE_ID_COOKIE = "DEVICEID"
|
||||
|
||||
// 扫码登录本地轮询参数,超时后把二维码交回前端,避免请求被反向代理掐断
|
||||
QRCODE_POLL_INTERVAL = 2 * time.Second
|
||||
QRCODE_POLL_TIMEOUT = 20 * time.Second
|
||||
|
||||
// Error codes
|
||||
UserInvalidOpenTokenError = "UserInvalidOpenToken"
|
||||
|
||||
// 密码登录返回该结果表示需要设备二次校验
|
||||
SecondDeviceAuthResult = -133
|
||||
)
|
||||
|
||||
func (y *Cloud189PC) SignatureHeader(url, method, params string, isFamily bool) map[string]string {
|
||||
@@ -195,9 +210,10 @@ 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, 1000, y.OrderBy, y.OrderDirection)
|
||||
resp, err := y.getFilesWithPage(ctx, fileId, isFamily, pageNum, pageSize, y.OrderBy, y.OrderDirection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -205,14 +221,23 @@ 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 < len(resp.FileListAO.FolderList); i++ {
|
||||
for i := 0; i < FolderCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FolderList[i])
|
||||
}
|
||||
for i := 0; i < len(resp.FileListAO.FileList); i++ {
|
||||
for i := 0; i < FileCount; i++ {
|
||||
resp.FileListAO.FileList[i].ParentID = fileId
|
||||
res = append(res, &resp.FileListAO.FileList[i])
|
||||
}
|
||||
|
||||
// 当前文件数量小于设定数量则跳出
|
||||
if PageCount < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -278,9 +303,72 @@ func (y *Cloud189PC) login() error {
|
||||
if y.LoginType == "qrcode" {
|
||||
return y.loginByQRCode()
|
||||
}
|
||||
if y.Username == "" || y.Password == "" {
|
||||
return errors.New("please fill in the username and password, or provide an access token / refresh token")
|
||||
}
|
||||
return y.loginByPassword()
|
||||
}
|
||||
|
||||
// 设备指纹,为空时生成并保存,服务端以此识别是否为同一台设备
|
||||
func (y *Cloud189PC) getUserFinger() string {
|
||||
if y.Addition.UserFinger == "" {
|
||||
y.Addition.UserFinger = fmt.Sprint(random.Rand.Int63n(9e9) + 1e9)
|
||||
op.MustSaveDriverStorage(y)
|
||||
}
|
||||
return y.Addition.UserFinger
|
||||
}
|
||||
|
||||
// 换取会话时携带的设备参数,与官方PC客户端保持一致
|
||||
// clientSn/jgOpenId 只在用户从官方客户端抓到并填写后才发送,避免上报一个服务端不认识的设备号
|
||||
func (y *Cloud189PC) deviceParams() map[string]string {
|
||||
params := map[string]string{"returnType": "JSON"}
|
||||
if y.Addition.ClientSn != "" {
|
||||
params["clientSn"] = y.Addition.ClientSn
|
||||
}
|
||||
if y.Addition.JgOpenId != "" {
|
||||
params["jgOpenId"] = y.Addition.JgOpenId
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// logbox接口的公共请求头,缺少user-finger和Referer会被判定为陌生设备
|
||||
func (y *Cloud189PC) loginHeaders(param BaseLoginParam) map[string]string {
|
||||
return map[string]string{
|
||||
"REQID": param.ReqId,
|
||||
"lt": param.Lt,
|
||||
"user-finger": y.getUserFinger(),
|
||||
"Referer": IF(param.Referer != "", param.Referer, AUTH_URL),
|
||||
}
|
||||
}
|
||||
|
||||
// 把已保存的设备标识写入cookie,避免重复触发设备二次校验
|
||||
func (y *Cloud189PC) applyDeviceID(jar http.CookieJar) {
|
||||
if y.Addition.DeviceID == "" {
|
||||
return
|
||||
}
|
||||
authUrl, err := url.Parse(AUTH_URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
jar.SetCookies(authUrl, []*http.Cookie{{
|
||||
Name: DEVICE_ID_COOKIE,
|
||||
Value: y.Addition.DeviceID,
|
||||
Domain: "e.189.cn",
|
||||
Path: "/",
|
||||
}})
|
||||
}
|
||||
|
||||
// 保存服务端下发的设备标识,下次登陆复用即可跳过设备二次校验
|
||||
func (y *Cloud189PC) saveDeviceID(res *resty.Response) {
|
||||
for _, cookie := range res.Cookies() {
|
||||
if cookie.Name == DEVICE_ID_COOKIE && cookie.Value != "" && cookie.Value != y.Addition.DeviceID {
|
||||
y.Addition.DeviceID = cookie.Value
|
||||
op.MustSaveDriverStorage(y)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) loginByPassword() (err error) {
|
||||
// 初始化登陆所需参数
|
||||
if y.loginParam == nil {
|
||||
@@ -289,9 +377,16 @@ func (y *Cloud189PC) loginByPassword() (err error) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 设备二次校验必须复用同一套登陆参数,此时不能销毁也不能重新初始化
|
||||
keepLoginParam := false
|
||||
defer func() {
|
||||
// 销毁验证码
|
||||
y.VCode = ""
|
||||
if keepLoginParam {
|
||||
y.Status = err.Error()
|
||||
op.MustSaveDriverStorage(y)
|
||||
return
|
||||
}
|
||||
// 销毁登陆参数
|
||||
y.loginParam = nil
|
||||
// 遇到错误,重新加载登陆参数(刷新验证码)
|
||||
@@ -309,17 +404,18 @@ func (y *Cloud189PC) loginByPassword() (err error) {
|
||||
|
||||
param := y.loginParam
|
||||
var loginresp LoginResp
|
||||
_, err = y.client.R().
|
||||
res, err := y.client.R().
|
||||
ForceContentType("application/json;charset=UTF-8").SetResult(&loginresp).
|
||||
SetHeaders(map[string]string{
|
||||
"REQID": param.ReqId,
|
||||
"lt": param.Lt,
|
||||
}).
|
||||
SetHeaders(y.loginHeaders(param.BaseLoginParam)).
|
||||
SetFormData(map[string]string{
|
||||
"version": "v2.0",
|
||||
"apToken": "",
|
||||
"appKey": APP_ID,
|
||||
"pageKey": "normal",
|
||||
"accountType": ACCOUNT_TYPE,
|
||||
"userName": param.RsaUsername,
|
||||
"password": param.RsaPassword,
|
||||
"epd": param.RsaPassword,
|
||||
"validateCode": y.VCode,
|
||||
"captchaToken": param.CaptchaToken,
|
||||
"returnUrl": RETURN_URL,
|
||||
@@ -335,17 +431,106 @@ func (y *Cloud189PC) loginByPassword() (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
y.saveDeviceID(res)
|
||||
|
||||
// 设备二次校验:服务端要求短信验证,保留登陆参数并引导填写短信验证码
|
||||
if loginresp.Result == SecondDeviceAuthResult {
|
||||
err = y.secondDeviceAuth(loginresp.Mobile)
|
||||
// 校验未完成时保留登陆参数,等待用户回填短信验证码
|
||||
keepLoginParam = err != nil && y.loginParam != nil
|
||||
return err
|
||||
}
|
||||
|
||||
if loginresp.ToUrl == "" {
|
||||
return fmt.Errorf("login failed,No toUrl obtained, msg: %s", loginresp.Msg)
|
||||
}
|
||||
|
||||
// 获取Session
|
||||
return y.getSessionByRedirectURL(loginresp.ToUrl)
|
||||
}
|
||||
|
||||
// 设备二次校验:先发短信,用户回填验证码后再提交
|
||||
func (y *Cloud189PC) secondDeviceAuth(mobile string) error {
|
||||
param := y.loginParam
|
||||
if mobile != "" {
|
||||
param.SecondAuthMobile = mobile
|
||||
}
|
||||
if param.SecondAuthMobile == "" {
|
||||
return errors.New("second device verification is required, but no mobile was returned")
|
||||
}
|
||||
|
||||
// 已填写短信验证码,直接提交校验
|
||||
if y.SmsCode != "" {
|
||||
smsCode := y.SmsCode
|
||||
y.SmsCode = ""
|
||||
op.MustSaveDriverStorage(y)
|
||||
return y.submitSecondDeviceAuth(smsCode)
|
||||
}
|
||||
|
||||
var smsResp LoginResp
|
||||
_, err := y.client.R().
|
||||
ForceContentType("application/json;charset=UTF-8").SetResult(&smsResp).
|
||||
SetHeaders(y.loginHeaders(param.BaseLoginParam)).
|
||||
SetFormData(map[string]string{
|
||||
"mobile": param.SecondAuthMobile,
|
||||
"appKey": APP_ID,
|
||||
}).
|
||||
Post(AUTH_URL + "/api/logbox/oauth2/sendSmsCodeForSecondAuth.do")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if smsResp.Result != 0 {
|
||||
return fmt.Errorf("failed to send the verification SMS: %s", smsResp.Msg)
|
||||
}
|
||||
// 保留登陆参数,等待用户回填短信验证码后重新保存
|
||||
return errors.New("second device verification is required, an SMS code has been sent, please fill it into `sms_code` and save again")
|
||||
}
|
||||
|
||||
// 提交短信验证码完成设备二次校验
|
||||
// 注意:该接口没有独立的短信码字段,短信码要加密后放在epd里(登陆时epd装的是密码)
|
||||
func (y *Cloud189PC) submitSecondDeviceAuth(smsCode string) error {
|
||||
param := y.loginParam
|
||||
var authResp LoginResp
|
||||
res, err := y.client.R().
|
||||
ForceContentType("application/json;charset=UTF-8").SetResult(&authResp).
|
||||
SetHeaders(y.loginHeaders(param.BaseLoginParam)).
|
||||
SetFormData(map[string]string{
|
||||
"mobile": param.SecondAuthMobile,
|
||||
"appKey": APP_ID,
|
||||
"userName": param.RsaUsername,
|
||||
"epd": param.encryptSecret(smsCode),
|
||||
"accountType": ACCOUNT_TYPE,
|
||||
"returnUrl": RETURN_URL,
|
||||
"isOauth2": "false",
|
||||
"cb_SaveName": "1",
|
||||
"state": "",
|
||||
"paramId": param.ParamId,
|
||||
}).
|
||||
Post(AUTH_URL + "/api/logbox/oauth2/submitForSecondAuth.do")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 校验通过后服务端会下发DEVICEID,保存下来以后就不会再触发二次校验
|
||||
y.saveDeviceID(res)
|
||||
|
||||
if authResp.Result != 0 {
|
||||
return fmt.Errorf("second device verification failed: %s", authResp.Msg)
|
||||
}
|
||||
if authResp.ToUrl == "" {
|
||||
return fmt.Errorf("second device verification failed, no toUrl obtained, msg: %s", authResp.Msg)
|
||||
}
|
||||
return y.getSessionByRedirectURL(authResp.ToUrl)
|
||||
}
|
||||
|
||||
// 用登陆结果的跳转地址换取会话
|
||||
func (y *Cloud189PC) getSessionByRedirectURL(redirectURL string) error {
|
||||
var erron RespErr
|
||||
var tokenInfo AppSessionResp
|
||||
_, err = y.client.R().
|
||||
_, err := y.client.R().
|
||||
SetResult(&tokenInfo).SetError(&erron).
|
||||
SetQueryParams(clientSuffix()).
|
||||
SetQueryParam("redirectURL", loginresp.ToUrl).
|
||||
SetQueryParams(y.deviceParams()).
|
||||
SetQueryParam("redirectURL", redirectURL).
|
||||
SetHeader("X-Request-ID", uuid.NewString()).
|
||||
Post(API_URL + "/getSessionForPC.action")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -355,14 +540,13 @@ func (y *Cloud189PC) loginByPassword() (err error) {
|
||||
return &erron
|
||||
}
|
||||
if tokenInfo.ResCode != 0 {
|
||||
err = fmt.Errorf(tokenInfo.ResMessage)
|
||||
return err
|
||||
return errors.New(tokenInfo.ResMessage)
|
||||
}
|
||||
y.Addition.AccessToken = tokenInfo.AccessToken
|
||||
y.Addition.RefreshToken = tokenInfo.RefreshToken
|
||||
y.tokenInfo = &tokenInfo
|
||||
op.MustSaveDriverStorage(y)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) loginByQRCode() error {
|
||||
@@ -373,66 +557,74 @@ func (y *Cloud189PC) loginByQRCode() error {
|
||||
}
|
||||
}
|
||||
|
||||
var state struct {
|
||||
Status int `json:"status"`
|
||||
RedirectUrl string `json:"redirectUrl"`
|
||||
Msg string `json:"msg"`
|
||||
// 本地轮询,扫码确认后自动继续,不需要用户反复保存
|
||||
deadline := time.Now().Add(QRCODE_POLL_TIMEOUT)
|
||||
lastStatus := -106
|
||||
for {
|
||||
state, err := y.checkQRCodeState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check QR code state: %w", err)
|
||||
}
|
||||
lastStatus = state.Status
|
||||
|
||||
switch state.Status {
|
||||
case 0: // 登录成功
|
||||
y.qrcodeParam = nil
|
||||
return y.getSessionByRedirectURL(state.RedirectUrl)
|
||||
case -106, -11002: // -106 等待扫描,-11002 已扫描等待确认
|
||||
case -11001: // 二维码过期
|
||||
y.qrcodeParam = nil
|
||||
return errors.New("QR code expired, please try again")
|
||||
default: // 其他错误
|
||||
y.qrcodeParam = nil
|
||||
return fmt.Errorf("QR code login failed with status %d: %s", state.Status, state.Msg)
|
||||
}
|
||||
|
||||
if time.Now().Add(QRCODE_POLL_INTERVAL).After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(QRCODE_POLL_INTERVAL)
|
||||
}
|
||||
|
||||
// 轮询超时,把二维码交回前端等待下一次保存
|
||||
if lastStatus == -11002 {
|
||||
return y.genQRCode("QR code has been scanned, please confirm the login on your phone and save again")
|
||||
}
|
||||
return y.genQRCode("QR code has not been scanned yet, please scan and save again")
|
||||
}
|
||||
|
||||
type qrCodeState struct {
|
||||
Status int `json:"status"`
|
||||
RedirectUrl string `json:"redirectUrl"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// 查询扫码状态,参数需与官方PC端一致,否则服务端不会返回授权结果
|
||||
func (y *Cloud189PC) checkQRCodeState() (*qrCodeState, error) {
|
||||
now := time.Now()
|
||||
var state qrCodeState
|
||||
_, err := y.client.R().
|
||||
SetHeaders(map[string]string{
|
||||
"Referer": AUTH_URL,
|
||||
"Reqid": y.qrcodeParam.ReqId,
|
||||
"lt": y.qrcodeParam.Lt,
|
||||
}).
|
||||
SetHeaders(y.loginHeaders(y.qrcodeParam.BaseLoginParam)).
|
||||
SetFormData(map[string]string{
|
||||
"appId": APP_ID,
|
||||
"clientType": CLIENT_TYPE,
|
||||
"returnUrl": RETURN_URL,
|
||||
"paramId": y.qrcodeParam.ParamId,
|
||||
"uuid": y.qrcodeParam.UUID,
|
||||
"encryuuid": y.qrcodeParam.EncryUUID,
|
||||
"date": formatDate(now),
|
||||
"timeStamp": fmt.Sprint(now.UTC().UnixNano() / 1e6),
|
||||
"appId": APP_ID,
|
||||
"clientType": QR_CLIENT_TYPE,
|
||||
"returnUrl": RETURN_URL,
|
||||
"paramId": y.qrcodeParam.ParamId,
|
||||
"uuid": y.qrcodeParam.UUID,
|
||||
"encryuuid": y.qrcodeParam.EncryUUID,
|
||||
"cb_SaveName": "3",
|
||||
"isOauth2": "false",
|
||||
"state": "",
|
||||
"date": formatDate(now),
|
||||
"timeStamp": fmt.Sprint(now.UTC().UnixNano() / 1e6),
|
||||
}).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&state).
|
||||
Post(AUTH_URL + "/api/logbox/oauth2/qrcodeLoginState.do")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check QR code state: %w", err)
|
||||
}
|
||||
|
||||
switch state.Status {
|
||||
case 0: // 登录成功
|
||||
var tokenInfo AppSessionResp
|
||||
_, err = y.client.R().
|
||||
SetResult(&tokenInfo).
|
||||
SetQueryParams(clientSuffix()).
|
||||
SetQueryParam("redirectURL", state.RedirectUrl).
|
||||
Post(API_URL + "/getSessionForPC.action")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tokenInfo.ResCode != 0 {
|
||||
return fmt.Errorf(tokenInfo.ResMessage)
|
||||
}
|
||||
y.Addition.AccessToken = tokenInfo.AccessToken
|
||||
y.Addition.RefreshToken = tokenInfo.RefreshToken
|
||||
y.tokenInfo = &tokenInfo
|
||||
op.MustSaveDriverStorage(y)
|
||||
return nil
|
||||
case -11001: // 二维码过期
|
||||
y.qrcodeParam = nil
|
||||
return errors.New("QR code expired, please try again")
|
||||
case -106: // 等待扫描
|
||||
return y.genQRCode("QR code has not been scanned yet, please scan and save again")
|
||||
case -11002: // 等待确认
|
||||
return y.genQRCode("QR code has been scanned, please confirm the login on your phone and save again")
|
||||
default: // 其他错误
|
||||
y.qrcodeParam = nil
|
||||
return fmt.Errorf("QR code login failed with status %d: %s", state.Status, state.Msg)
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) genQRCode(text string) error {
|
||||
@@ -458,8 +650,9 @@ func (y *Cloud189PC) genQRCode(text string) error {
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) initBaseParams() (*BaseLoginParam, error) {
|
||||
// 清除cookie
|
||||
// 清除cookie,并带上已保存的设备标识
|
||||
jar, _ := cookiejar.New(nil)
|
||||
y.applyDeviceID(jar)
|
||||
y.client.SetCookieJar(jar)
|
||||
|
||||
res, err := y.client.R().
|
||||
@@ -474,14 +667,98 @@ func (y *Cloud189PC) initBaseParams() (*BaseLoginParam, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 当前登陆页把lt/reqId放在跳转地址上,老页面则写在页内变量里,两种都要支持
|
||||
param, err := parseBaseParamFromRedirect(res.RawResponse.Request.URL)
|
||||
if err != nil {
|
||||
param, err = parseBaseParamFromPage(res.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return param, nil
|
||||
}
|
||||
|
||||
// 跳转地址上没有paramId,需要再问一次appConf.do
|
||||
var appConf AppConfResp
|
||||
_, err = y.client.R().
|
||||
SetHeaders(y.loginHeaders(*param)).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&appConf).
|
||||
SetFormData(map[string]string{
|
||||
"version": "2.0",
|
||||
"appKey": APP_ID,
|
||||
}).
|
||||
Post(AUTH_URL + "/api/logbox/oauth2/appConf.do")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !appConf.Succeeded() || appConf.Data.ParamId == "" {
|
||||
return nil, fmt.Errorf("failed to get the login paramId: %s", appConf.Msg)
|
||||
}
|
||||
param.ParamId = appConf.Data.ParamId
|
||||
return param, nil
|
||||
}
|
||||
|
||||
// parseBaseParamFromRedirect 从logbox跳转地址提取登陆参数,并以该地址作为后续请求的Referer
|
||||
func parseBaseParamFromRedirect(finalUrl *url.URL) (*BaseLoginParam, error) {
|
||||
if finalUrl == nil {
|
||||
return nil, errors.New("no login page redirect")
|
||||
}
|
||||
query := finalUrl.Query()
|
||||
lt, reqId := query.Get("lt"), query.Get("reqId")
|
||||
if lt == "" || reqId == "" {
|
||||
return nil, errors.New("no lt/reqId in the login page redirect")
|
||||
}
|
||||
return &BaseLoginParam{
|
||||
CaptchaToken: regexp.MustCompile(`'captchaToken' value='(.+?)'`).FindStringSubmatch(res.String())[1],
|
||||
Lt: regexp.MustCompile(`lt = "(.+?)"`).FindStringSubmatch(res.String())[1],
|
||||
ParamId: regexp.MustCompile(`paramId = "(.+?)"`).FindStringSubmatch(res.String())[1],
|
||||
ReqId: regexp.MustCompile(`reqId = "(.+?)"`).FindStringSubmatch(res.String())[1],
|
||||
Lt: lt,
|
||||
ReqId: reqId,
|
||||
Referer: finalUrl.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseBaseParamFromPage 兼容把参数写在页内变量里的老登陆页
|
||||
func parseBaseParamFromPage(body string) (*BaseLoginParam, error) {
|
||||
lt, err := matchLoginParam(body, `lt = "(.+?)"`, "lt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqId, err := matchLoginParam(body, `reqId = "(.+?)"`, "reqId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paramId, err := matchLoginParam(body, `paramId = "(.+?)"`, "paramId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 老页面才有内嵌的图形验证码token
|
||||
captchaToken, _ := matchLoginParam(body, `'captchaToken' value='(.+?)'`, "captchaToken")
|
||||
|
||||
param := &BaseLoginParam{
|
||||
CaptchaToken: captchaToken,
|
||||
Lt: lt,
|
||||
ParamId: paramId,
|
||||
ReqId: reqId,
|
||||
}
|
||||
encryptUrl, _ := matchLoginParam(body, `encryptUrl = "(.+?)"`, "encryptUrl")
|
||||
param.Referer = AUTH_URL + "/api/logbox/separate/web/index.html?" + strings.Join([]string{
|
||||
"appId=" + url.QueryEscape(APP_ID),
|
||||
"lt=" + url.QueryEscape(param.Lt),
|
||||
"reqId=" + url.QueryEscape(param.ReqId),
|
||||
}, "&")
|
||||
if encryptUrl != "" {
|
||||
param.Referer += "&encryptUrl=" + url.QueryEscape(encryptUrl)
|
||||
}
|
||||
return param, nil
|
||||
}
|
||||
|
||||
// matchLoginParam 从登陆页面提取参数,缺失时返回可读的错误而不是panic
|
||||
func matchLoginParam(body, pattern, name string) (string, error) {
|
||||
matches := regexp.MustCompile(pattern).FindStringSubmatch(body)
|
||||
if len(matches) < 2 {
|
||||
return "", fmt.Errorf("failed to get %s from the login page", name)
|
||||
}
|
||||
return matches[1], nil
|
||||
}
|
||||
|
||||
/* 初始化登陆需要的参数
|
||||
* 如果遇到验证码返回错误
|
||||
*/
|
||||
@@ -506,12 +783,13 @@ func (y *Cloud189PC) initLoginParam() error {
|
||||
}
|
||||
|
||||
y.loginParam.jRsaKey = fmt.Sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----", encryptConf.Data.PubKey)
|
||||
y.loginParam.RsaUsername = encryptConf.Data.Pre + RsaEncrypt(y.loginParam.jRsaKey, y.Username)
|
||||
y.loginParam.RsaPassword = encryptConf.Data.Pre + RsaEncrypt(y.loginParam.jRsaKey, y.Password)
|
||||
y.loginParam.rsaPrefix = encryptConf.Data.Pre
|
||||
y.loginParam.RsaUsername = y.loginParam.encryptSecret(y.Username)
|
||||
y.loginParam.RsaPassword = y.loginParam.encryptSecret(y.Password)
|
||||
|
||||
// 判断是否需要验证码
|
||||
resp, err := y.client.R().
|
||||
SetHeader("REQID", y.loginParam.ReqId).
|
||||
SetHeaders(y.loginHeaders(y.loginParam.BaseLoginParam)).
|
||||
SetFormData(map[string]string{
|
||||
"appKey": APP_ID,
|
||||
"accountType": ACCOUNT_TYPE,
|
||||
@@ -566,6 +844,7 @@ func (y *Cloud189PC) initQRCodeParam() (err error) {
|
||||
|
||||
var qrcodeParam QRLoginParam
|
||||
_, err = y.client.R().
|
||||
SetHeaders(y.loginHeaders(*baseParam)).
|
||||
SetFormData(map[string]string{"appId": APP_ID}).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&qrcodeParam).
|
||||
@@ -573,6 +852,9 @@ func (y *Cloud189PC) initQRCodeParam() (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if qrcodeParam.UUID == "" {
|
||||
return errors.New("failed to get the QR code uuid")
|
||||
}
|
||||
qrcodeParam.BaseLoginParam = *baseParam
|
||||
y.qrcodeParam = &qrcodeParam
|
||||
|
||||
@@ -593,6 +875,7 @@ func (y *Cloud189PC) refreshSessionWithRetry(retryCount int) (err error) {
|
||||
_, err = y.client.R().
|
||||
SetResult(&userSessionResp).SetError(&erron).
|
||||
SetQueryParams(clientSuffix()).
|
||||
SetQueryParams(y.deviceParams()).
|
||||
SetQueryParams(map[string]string{
|
||||
"appId": APP_ID,
|
||||
"accessToken": y.tokenInfo.AccessToken,
|
||||
@@ -633,12 +916,11 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
|
||||
return errors.New("refresh token failed after maximum retries")
|
||||
}
|
||||
|
||||
var erron RespErr
|
||||
var tokenInfo AppSessionResp
|
||||
// 该接口刷新失败时以HTTP 200返回 result/msg,SetError不会触发,必须解析响应体判断
|
||||
var tokenInfo RefreshTokenResp
|
||||
_, err = y.client.R().
|
||||
SetResult(&tokenInfo).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetError(&erron).
|
||||
SetFormData(map[string]string{
|
||||
"clientId": APP_ID,
|
||||
"refreshToken": y.tokenInfo.RefreshToken,
|
||||
@@ -651,7 +933,8 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
|
||||
}
|
||||
|
||||
// 如果刷新失败,返回错误给上层处理
|
||||
if erron.HasError() {
|
||||
if tokenInfo.HasError() {
|
||||
refreshErr := tokenInfo.Error()
|
||||
if y.Addition.RefreshToken != "" {
|
||||
y.Addition.RefreshToken = ""
|
||||
op.MustSaveDriverStorage(y)
|
||||
@@ -659,7 +942,11 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
|
||||
|
||||
// 根据登录类型决定下一步行为
|
||||
if y.LoginType == "qrcode" {
|
||||
return errors.New("QR code session has expired, please re-scan the code to log in")
|
||||
return fmt.Errorf("QR code session has expired, please re-scan the code to log in: %s", refreshErr)
|
||||
}
|
||||
// 没有账号密码时无法回退到完整登录,直接把刷新失败的原因返回
|
||||
if y.Username == "" || y.Password == "" {
|
||||
return errors.New(refreshErr)
|
||||
}
|
||||
// 密码登录模式下,尝试回退到完整登录
|
||||
return y.login()
|
||||
@@ -667,7 +954,8 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
|
||||
|
||||
y.Addition.AccessToken = tokenInfo.AccessToken
|
||||
y.Addition.RefreshToken = tokenInfo.RefreshToken
|
||||
y.tokenInfo = &tokenInfo
|
||||
y.tokenInfo.AccessToken = tokenInfo.AccessToken
|
||||
y.tokenInfo.RefreshToken = tokenInfo.RefreshToken
|
||||
op.MustSaveDriverStorage(y)
|
||||
return y.refreshSessionWithRetry(retryCount + 1)
|
||||
}
|
||||
|
||||
@@ -328,7 +328,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -22,8 +23,9 @@ type AliyundriveOpen struct {
|
||||
|
||||
DriveId string
|
||||
|
||||
limiter *limiter
|
||||
ref *AliyundriveOpen
|
||||
limiter *limiter
|
||||
ref *AliyundriveOpen
|
||||
callback *callbackRegistration
|
||||
}
|
||||
|
||||
func (d *AliyundriveOpen) Config() driver.Config {
|
||||
@@ -35,6 +37,7 @@ 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"
|
||||
@@ -52,6 +55,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -65,6 +69,10 @@ 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
|
||||
@@ -119,10 +127,16 @@ 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
|
||||
return &model.Link{
|
||||
link := &model.Link{
|
||||
URL: url,
|
||||
Expiration: &exp,
|
||||
}, nil
|
||||
}
|
||||
if args.Redirect {
|
||||
return link, nil
|
||||
}
|
||||
link.URL = ""
|
||||
link.RangeReader = stream.RateLimitRangeReaderFunc(d.callbackRangeReader(url, file.GetSize()))
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (d *AliyundriveOpen) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
|
||||
|
||||
@@ -8,19 +8,20 @@ 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"`
|
||||
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"`
|
||||
CallbackConcurrency int `json:"callback_concurrency" type:"number" default:"1" help:"Maximum active proxied downloads shared by Aliyun user"`
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
|
||||
@@ -42,6 +42,7 @@ 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"
|
||||
|
||||
@@ -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 encrytion")
|
||||
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support encryption")
|
||||
}
|
||||
|
||||
mu := &sync.Mutex{}
|
||||
|
||||
@@ -56,8 +56,8 @@ func (d *Emby) Init(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(d.Username) == "" || strings.TrimSpace(d.Password) == "" {
|
||||
return fmt.Errorf("please provide api_key+user_id or username+password")
|
||||
if strings.TrimSpace(d.Username) == "" {
|
||||
return fmt.Errorf("please provide api_key+user_id or username(+password)")
|
||||
}
|
||||
|
||||
if err := d.login(ctx); err != nil {
|
||||
|
||||
@@ -154,15 +154,17 @@ func (d *Github) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
} else {
|
||||
ret := make([]model.Obj, 0, len(obj.Entries))
|
||||
for _, entry := range obj.Entries {
|
||||
if entry.Name != ".gitkeep" {
|
||||
ret = append(ret, entry.toModelObj())
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
ret := make([]model.Obj, 0, len(obj.Entries))
|
||||
for _, entry := range obj.Entries {
|
||||
if entry.Name == ".gitkeep" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, entry.toModelObj())
|
||||
}
|
||||
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) {
|
||||
|
||||
+18
-17
@@ -7,23 +7,24 @@ 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."`
|
||||
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."`
|
||||
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}}"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
@@ -35,7 +33,8 @@ func (o *Object) toModelObj() *model.Object {
|
||||
return &model.Object{
|
||||
Name: o.Name,
|
||||
Size: o.Size,
|
||||
Modified: time.Unix(0, 0),
|
||||
Modified: githubZeroTime,
|
||||
Ctime: githubZeroTime,
|
||||
IsFolder: o.Type == "dir",
|
||||
Path: utils.FixAndCleanPath(o.Path),
|
||||
}
|
||||
@@ -69,7 +68,8 @@ func (o *TreeObjResp) toModelObj() *model.Object {
|
||||
return &model.Object{
|
||||
Name: o.Path,
|
||||
Size: o.Size,
|
||||
Modified: time.Unix(0, 0),
|
||||
Modified: githubZeroTime,
|
||||
Ctime: githubZeroTime,
|
||||
IsFolder: o.Type == "tree",
|
||||
Path: utils.FixAndCleanPath(o.Path),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
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)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
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{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
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
|
||||
}
|
||||
+97
-151
@@ -1,4 +1,4 @@
|
||||
package template
|
||||
package ilanzou
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -27,11 +28,13 @@ type ILanZou struct {
|
||||
model.Storage
|
||||
Addition
|
||||
|
||||
userID string
|
||||
account string
|
||||
upClient *resty.Client
|
||||
conf Conf
|
||||
config driver.Config
|
||||
userID string
|
||||
account string
|
||||
apiClient *resty.Client
|
||||
linkClient *resty.Client
|
||||
upClient *resty.Client
|
||||
conf Conf
|
||||
config driver.Config
|
||||
}
|
||||
|
||||
func (d *ILanZou) Config() driver.Config {
|
||||
@@ -43,6 +46,18 @@ 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)
|
||||
@@ -77,8 +92,7 @@ func (d *ILanZou) List(ctx context.Context, dir model.Obj, args model.ListArgs)
|
||||
"folderId=" + dir.GetID(),
|
||||
"type=0",
|
||||
}
|
||||
queryString := strings.Join(params, "&")
|
||||
req.SetQueryString(queryString).SetResult(&resp)
|
||||
req.SetQueryString(strings.Join(params, "&")).SetResult(&resp)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -96,14 +110,12 @@ 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),
|
||||
// Path: "",
|
||||
ID: strconv.FormatInt(f.FileId, 10),
|
||||
Name: f.FileName,
|
||||
Size: f.FileSize * 1024,
|
||||
Modified: updTime,
|
||||
Ctime: updTime,
|
||||
IsFolder: false,
|
||||
// HashInfo: utils.HashInfo{},
|
||||
}
|
||||
if f.FileType == 2 {
|
||||
obj.IsFolder = true
|
||||
@@ -133,26 +145,23 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
"devVersion=" + url.QueryEscape(d.conf.devVersion),
|
||||
"appVersion=",
|
||||
"timestamp=" + tsStr,
|
||||
"appToken=" + url.QueryEscape(d.Token),
|
||||
"appToken=" + appTokenQueryValue(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()
|
||||
// get the url after redirect
|
||||
req := base.NoRedirectClient.R().SetContext(ctx)
|
||||
|
||||
req := d.linkClient.R().SetContext(ctx)
|
||||
req.SetHeaders(map[string]string{
|
||||
"Origin": d.conf.site,
|
||||
"Referer": d.conf.site + "/",
|
||||
@@ -162,7 +171,6 @@ 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
|
||||
@@ -172,49 +180,50 @@ 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}
|
||||
return &link, nil
|
||||
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
|
||||
}
|
||||
|
||||
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(),
|
||||
// Path: "",
|
||||
Name: dirName,
|
||||
Size: 0,
|
||||
Modified: time.Now(),
|
||||
Ctime: time.Now(),
|
||||
IsFolder: true,
|
||||
// HashInfo: utils.HashInfo{},
|
||||
}, nil
|
||||
return &model.Object{ID: utils.Json.Get(res, "list", 0, "id").ToString(), Name: dirName, Modified: time.Now(), Ctime: time.Now(), IsFolder: true}, 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
|
||||
@@ -226,58 +235,47 @@ 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(),
|
||||
// Path: "",
|
||||
Name: newName,
|
||||
Size: srcObj.GetSize(),
|
||||
Modified: time.Now(),
|
||||
Ctime: srcObj.CreateTime(),
|
||||
IsFolder: srcObj.IsDir(),
|
||||
}, nil
|
||||
return &model.Object{ID: srcObj.GetID(), 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
|
||||
const (
|
||||
DefaultPartSize = 1024 * 1024 * 8
|
||||
|
||||
// The results endpoint is eventually consistent after the upload commit.
|
||||
maxUploadCommitRetries = 10
|
||||
uploadCommitRetryDelay = time.Second
|
||||
|
||||
linkHeadTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -288,55 +286,31 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// get upToken
|
||||
fileSizeKiB := (s.GetSize() + 1023) / 1024
|
||||
if fileSizeKiB < 1 {
|
||||
fileSizeKiB = 1
|
||||
}
|
||||
res, err := d.proved("/7n/getUpToken", http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(base.Json{
|
||||
"fileId": "",
|
||||
"fileName": s.GetName(),
|
||||
"fileSize": s.GetSize()/1024 + 1,
|
||||
"folderId": dstDir.GetID(),
|
||||
"md5": etag,
|
||||
"type": 1,
|
||||
})
|
||||
req.SetBody(base.Json{"fileId": "", "fileName": s.GetName(), "fileSize": fileSizeKiB, "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
|
||||
err := utils.Json.Unmarshal(res, &resp)
|
||||
if err != nil {
|
||||
if err := utils.Json.Unmarshal(res, &resp); 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()
|
||||
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,
|
||||
})
|
||||
// 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})
|
||||
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
|
||||
}
|
||||
@@ -347,40 +321,27 @@ 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
|
||||
}
|
||||
etag := utils.Json.Get(res.Body(), "etag").ToString()
|
||||
parts = append(parts, Part{
|
||||
PartNumber: i,
|
||||
ETag: etag,
|
||||
})
|
||||
parts = append(parts, Part{PartNumber: i, ETag: utils.Json.Get(res.Body(), "etag").ToString()})
|
||||
}
|
||||
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 < 10; i++ {
|
||||
for i := 0; i < maxUploadCommitRetries; i++ {
|
||||
_, err = d.unproved("/7n/results", http.MethodPost, func(req *resty.Request) {
|
||||
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)
|
||||
req.SetQueryString("tokenList=" + token + "&tokenTime=" + time.Now().Format("Mon Jan 02 2006 15:04:05 GMT-0700 (MST)")).SetResult(&resp)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -391,22 +352,17 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
|
||||
if resp.List[0].Status == 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second * 1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(uploadCommitRetryDelay):
|
||||
}
|
||||
}
|
||||
file := resp.List[0]
|
||||
if file.Status != 1 {
|
||||
return nil, fmt.Errorf("upload failed, status: %d", resp.List[0].Status)
|
||||
return nil, fmt.Errorf("upload failed, status: %d", file.Status)
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
func (d *ILanZou) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
@@ -419,18 +375,8 @@ 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: total,
|
||||
UsedSpace: used,
|
||||
},
|
||||
}, nil
|
||||
return &model.StorageDetails{DiskUsage: model.DiskUsage{TotalSpace: totalSize + rewardSize + vipSize, 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,4 +1,4 @@
|
||||
package template
|
||||
package ilanzou
|
||||
|
||||
import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package template
|
||||
package ilanzou
|
||||
|
||||
type ListResp struct {
|
||||
Msg string `json:"msg"`
|
||||
|
||||
+36
-23
@@ -1,4 +1,4 @@
|
||||
package template
|
||||
package ilanzou
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
@@ -18,10 +18,7 @@ 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
|
||||
@@ -35,20 +32,33 @@ func (d *ILanZou) login() error {
|
||||
|
||||
func getTimestamp(secret []byte) (int64, string, error) {
|
||||
ts := time.Now().UnixMilli()
|
||||
tsStr := strconv.FormatInt(ts, 10)
|
||||
res, err := mopan.AesEncrypt([]byte(tsStr), secret)
|
||||
res, err := mopan.AesEncrypt([]byte(strconv.FormatInt(ts, 10)), 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) {
|
||||
_, ts_str, err := getTimestamp(d.conf.secret)
|
||||
_, timestamp, err := getTimestamp(d.conf.secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := []string{
|
||||
"uuid=" + url.QueryEscape(d.UUID),
|
||||
"devType=6",
|
||||
@@ -56,48 +66,51 @@ func (d *ILanZou) request(pathname, method string, callback base.ReqCallback, pr
|
||||
"devModel=chrome",
|
||||
"devVersion=" + url.QueryEscape(d.conf.devVersion),
|
||||
"appVersion=",
|
||||
"timestamp=" + ts_str,
|
||||
"timestamp=" + timestamp,
|
||||
}
|
||||
|
||||
if proved {
|
||||
params = append(params, "appToken="+url.QueryEscape(d.Token))
|
||||
params = append(params, "appToken="+appTokenQueryValue(d.Token))
|
||||
}
|
||||
|
||||
params = append(params, "extra=2")
|
||||
|
||||
queryString := strings.Join(params, "&")
|
||||
|
||||
req := base.RestyClient.R()
|
||||
if d.apiClient == nil {
|
||||
return nil, fmt.Errorf("iLanzou driver is not initialized")
|
||||
}
|
||||
req := d.apiClient.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+"?"+queryString)
|
||||
res, err := req.Execute(method, d.conf.base+pathname+"?"+strings.Join(params, "&"))
|
||||
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 == "") {
|
||||
err = d.login()
|
||||
if err != nil {
|
||||
if err := d.login(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.request(pathname, method, callback, proved, true)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+10
-16
@@ -170,23 +170,17 @@ var findKVReg = regexp.MustCompile(`'(.+?)':('?([^' },]*)'?)`) // 拆分kv
|
||||
|
||||
// 根据key查询js变量
|
||||
func findJSVarFunc(key, data string) string {
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
re := regexp.MustCompile(`var\s+` + regexp.QuoteMeta(key) + `\s*=\s*['"]?([^'"]*)['"]?\s*;`)
|
||||
matches := re.FindAllStringSubmatch(data, -1)
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[1]
|
||||
for i := len(matches) - 1; i >= 0; i-- {
|
||||
if matches[i][1] != "" {
|
||||
return matches[i][1]
|
||||
}
|
||||
}
|
||||
return matches[len(matches)-1][1]
|
||||
}
|
||||
|
||||
var findFunction = regexp.MustCompile(`(?ims)^function[^{]+`)
|
||||
@@ -304,7 +298,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 sgin")
|
||||
return nil, fmt.Errorf("not find file sign")
|
||||
}
|
||||
return formToMap(forms[1]), nil
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ type FileOrFolderByShareUrl struct {
|
||||
//T int `json:"t"`
|
||||
|
||||
// 文件夹特有
|
||||
IsFloder bool `json:"-"`
|
||||
IsFolder bool `json:"-"`
|
||||
|
||||
//
|
||||
Url string `json:"-"`
|
||||
@@ -162,7 +162,7 @@ func (f *FileOrFolderByShareUrl) GetSize() int64 {
|
||||
}
|
||||
return *f.size
|
||||
}
|
||||
func (f *FileOrFolderByShareUrl) IsDir() bool { return f.IsFloder }
|
||||
func (f *FileOrFolderByShareUrl) IsDir() bool { return f.IsFolder }
|
||||
func (f *FileOrFolderByShareUrl) ModTime() time.Time {
|
||||
if f.time == nil {
|
||||
time := MustParseTime(f.Time)
|
||||
|
||||
+90
-41
@@ -157,25 +157,43 @@ func (d *LanZou) request(url string, method string, callback base.ReqCallback, u
|
||||
}
|
||||
|
||||
func (d *LanZou) Login() ([]*http.Cookie, error) {
|
||||
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
|
||||
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
|
||||
}
|
||||
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
|
||||
return nil, errors.New("acw_sc__v2 validation error")
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -295,7 +313,37 @@ var findSubFolderReg = regexp.MustCompile(`(?i)(?:folderlink|mbxfolder).+href="/
|
||||
var findDownPageParamReg = regexp.MustCompile(`<iframe.*?src="(.+?)"`)
|
||||
|
||||
// 获取文件ID
|
||||
var findFileIDReg = regexp.MustCompile(`'/ajaxm\.php\?file=(\d+)'`)
|
||||
var findFileIDReg = regexp.MustCompile(`'/ajax(?:file|m)\.php\?file=(\d+)'`)
|
||||
|
||||
// 2026-10 改版:文件页将下载参数移入 /fn? 内页,接口变为 apifile 绝对地址并携带签名
|
||||
var (
|
||||
fnDomainReg = regexp.MustCompile(`var\s+domain[12]\s*=\s*'([^']*(?:ajaxfile|ajaxm)\.php\?file=(\d+)[^']*)'`)
|
||||
fnSignReg = regexp.MustCompile(`var\s+wp_sign\s*=\s*'([^']*)'`)
|
||||
fnAjaxDataReg = regexp.MustCompile(`var\s+ajaxdata\s*=\s*'([^']*)'`)
|
||||
)
|
||||
|
||||
// parseFnPage 从改版后的 /fn? 内页提取下载接口地址与签名表单
|
||||
// 对应页面 JS:POST domain1 {'action':'downprocess','websignkey':ajaxdata,'signs':ajaxdata,'sign':wp_sign,'websign':'2','kd':kdns,'ves':1}
|
||||
func parseFnPage(pageData string) (string, map[string]string, error) {
|
||||
matches := fnDomainReg.FindStringSubmatch(pageData)
|
||||
if len(matches) < 3 {
|
||||
return "", nil, fmt.Errorf("not find fn ajax url")
|
||||
}
|
||||
sign := fnSignReg.FindStringSubmatch(pageData)
|
||||
ajaxdata := fnAjaxDataReg.FindStringSubmatch(pageData)
|
||||
if len(sign) < 2 || len(ajaxdata) < 2 {
|
||||
return "", nil, fmt.Errorf("not find fn sign")
|
||||
}
|
||||
return matches[1], map[string]string{
|
||||
"action": "downprocess",
|
||||
"websignkey": ajaxdata[1],
|
||||
"signs": ajaxdata[1],
|
||||
"sign": sign[1],
|
||||
"websign": "2",
|
||||
"kd": "1",
|
||||
"ves": "1",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 获取分享链接主界面
|
||||
func (d *LanZou) getShareUrlHtml(shareID string) (string, error) {
|
||||
@@ -394,15 +442,13 @@ func (d *LanZou) getFilesByShareUrl(shareID, pwd string, sharePageData string) (
|
||||
}
|
||||
param["p"] = pwd
|
||||
|
||||
fileIDs := findFileIDReg.FindStringSubmatch(sharePageData)
|
||||
var fileID string
|
||||
if len(fileIDs) > 1 {
|
||||
fileID = fileIDs[1]
|
||||
} else {
|
||||
matches := findFileIDReg.FindStringSubmatch(sharePageData)
|
||||
if len(matches) < 2 {
|
||||
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(d.ShareUrl+"/ajaxm.php?file="+fileID, func(req *resty.Request) { req.SetFormData(param) }, &resp)
|
||||
_, err = d.post(ajaxUrl, func(req *resty.Request) { req.SetFormData(param) }, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -421,20 +467,23 @@ func (d *LanZou) getFilesByShareUrl(shareID, pwd string, sharePageData string) (
|
||||
return nil, err
|
||||
}
|
||||
nextPageData := RemoveNotes(string(data))
|
||||
param, err = htmlJsonToMap(nextPageData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileIDs := findFileIDReg.FindStringSubmatch(nextPageData)
|
||||
var fileID string
|
||||
if len(fileIDs) > 1 {
|
||||
fileID = fileIDs[1]
|
||||
var resp FileShareInfoAndUrlResp[int]
|
||||
matches := findFileIDReg.FindStringSubmatch(nextPageData)
|
||||
if len(matches) >= 2 {
|
||||
// 旧版结构:相对路径 /ajaxm.php?file=N
|
||||
param, err = htmlJsonToMap(nextPageData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ajaxUrl := d.ShareUrl + matches[0][1:len(matches[0])-1]
|
||||
_, err = d.post(ajaxUrl, func(req *resty.Request) { req.SetFormData(param) }, &resp)
|
||||
} else if fnUrl, fnForm, ferr := parseFnPage(nextPageData); ferr == nil {
|
||||
// 2026-10 改版结构:/fn? 内页携带 apifile 绝对地址与签名参数
|
||||
_, err = d.post(fnUrl, func(req *resty.Request) { req.SetFormData(fnForm) }, &resp)
|
||||
} else {
|
||||
return nil, fmt.Errorf("not find file id")
|
||||
}
|
||||
var resp FileShareInfoAndUrlResp[int]
|
||||
_, err = d.post(d.ShareUrl+"/ajaxm.php?file="+fileID, func(req *resty.Request) { req.SetFormData(param) }, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -572,14 +621,14 @@ func (d *LanZou) getFolderByShareUrl(pwd string, sharePageData string) ([]FileOr
|
||||
|
||||
files := make([]FileOrFolderByShareUrl, 0)
|
||||
// vip获取文件夹
|
||||
floders := findSubFolderReg.FindAllStringSubmatch(sharePageData, -1)
|
||||
for _, floder := range floders {
|
||||
if len(floder) == 3 {
|
||||
folders := findSubFolderReg.FindAllStringSubmatch(sharePageData, -1)
|
||||
for _, folder := range folders {
|
||||
if len(folder) == 3 {
|
||||
files = append(files, FileOrFolderByShareUrl{
|
||||
// Pwd: pwd, // 子文件夹不加密
|
||||
ID: floder[1],
|
||||
NameAll: floder[2],
|
||||
IsFloder: true,
|
||||
ID: folder[1],
|
||||
NameAll: folder[2],
|
||||
IsFolder: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -153,8 +152,7 @@ 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 {
|
||||
typeName := utils.GetFileType(f.Name())
|
||||
if typeName == conf.IMAGE || typeName == conf.VIDEO {
|
||||
if d.supportsThumbnail(f.Name()) {
|
||||
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()))
|
||||
@@ -240,7 +238,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(file)
|
||||
buf, thumbPath, err = d.getThumb(ctx, file)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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."`
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//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")
|
||||
}
|
||||
+22
-2
@@ -2,6 +2,7 @@ package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -127,7 +128,19 @@ func (d *Local) removeThumbCache(fullPath string) {
|
||||
_ = os.Remove(thumbPath)
|
||||
}
|
||||
|
||||
func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
|
||||
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) {
|
||||
fullPath := file.GetPath()
|
||||
if d.ThumbCacheFolder != "" {
|
||||
// skip if the file is a thumbnail
|
||||
@@ -140,12 +153,19 @@ func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
|
||||
}
|
||||
}
|
||||
var srcBuf *bytes.Buffer
|
||||
if utils.GetFileType(file.GetName()) == conf.VIDEO {
|
||||
typeName := utils.GetFileType(file.GetName())
|
||||
if typeName == 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 {
|
||||
|
||||
@@ -2,7 +2,6 @@ package onedrive_sharelink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,30 +12,28 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
internalNet "github.com/OpenListTeam/OpenList/v4/internal/net"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// NewNoRedirectClient creates an HTTP client that doesn't follow redirects
|
||||
func NewNoRedirectCLient() *http.Client {
|
||||
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
|
||||
},
|
||||
client := internalNet.NewHttpClient()
|
||||
client.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(link, password string) (string, error) {
|
||||
func getCookiesWithPassword(ctx context.Context, link, password string) (string, error) {
|
||||
// Send GET request
|
||||
resp, err := http.Get(link)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -95,16 +92,18 @@ func getCookiesWithPassword(link, password string) (string, error) {
|
||||
"__VIEWSTATEENCRYPTED": []string{""},
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
client := NewNoRedirectCLient()
|
||||
// Send the POST request, preventing redirects
|
||||
resp, err = client.PostForm(newURL, data)
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, newURL, strings.NewReader(data.Encode()))
|
||||
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()
|
||||
@@ -153,6 +152,7 @@ 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(d.ShareLinkURL, d.ShareLinkPassword)
|
||||
cookie, err := getCookiesWithPassword(ctx, d.ShareLinkURL, d.ShareLinkPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,6 +197,7 @@ 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
|
||||
@@ -205,6 +206,7 @@ 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, "/")
|
||||
@@ -289,9 +291,9 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
|
||||
}
|
||||
tempHeader["Content-Type"] = []string{"application/json;odata=verbose"}
|
||||
|
||||
client := &http.Client{}
|
||||
client := base.HttpClient
|
||||
postUrl := strings.Join(redirectSplitURL[:len(redirectSplitURL)-3], "/") + "/_api/v2.1/graphql"
|
||||
req, err = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(graphqlVar))
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, postUrl, strings.NewReader(graphqlVar))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -326,7 +328,10 @@ 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, _ = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar))
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header = tempHeader
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,11 @@ 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:"true" default:""`
|
||||
RefreshToken string `json:"refresh_token" required:"false" 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{
|
||||
|
||||
+34
-12
@@ -100,12 +100,13 @@ 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"
|
||||
// 使用 用户填写的 CaptchaToken —————— (验证后的captcha_token)
|
||||
if d.GetCaptchaToken() == "" {
|
||||
if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
// Always refresh captcha token before signin (it may be expired)
|
||||
if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var e ErrResp
|
||||
@@ -125,7 +126,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -159,9 +165,14 @@ 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 = jsoniter.Get(data, "refresh_token").ToString()
|
||||
d.AccessToken = jsoniter.Get(data, "access_token").ToString()
|
||||
d.RefreshToken = newRefreshToken
|
||||
d.AccessToken = newAccessToken
|
||||
d.Common.SetUserID(jsoniter.Get(data, "sub").ToString())
|
||||
d.Addition.RefreshToken = d.RefreshToken
|
||||
op.MustSaveDriverStorage(d)
|
||||
@@ -197,12 +208,18 @@ func (d *PikPak) request(url string, method string, callback base.ReqCallback, r
|
||||
case 0:
|
||||
return res.Body(), nil
|
||||
case 4122, 4121, 16:
|
||||
// access_token 过期
|
||||
if strings.Contains(url, "/v1/auth/") || strings.Contains(url, "/v1/shield/captcha/") {
|
||||
return nil, errors.New(e.Error())
|
||||
}
|
||||
// access_token expired, refresh and retry
|
||||
if err1 := d.refreshToken(d.RefreshToken); err1 != nil {
|
||||
return nil, err1
|
||||
}
|
||||
return d.request(url, method, callback, resp)
|
||||
case 9: // 验证码token过期
|
||||
case 9: // captcha token expired
|
||||
if strings.Contains(url, "/v1/shield/captcha/") {
|
||||
return nil, errors.New(e.Error())
|
||||
}
|
||||
if err = d.RefreshCaptchaTokenAtLogin(GetAction(method, url), d.GetUserID()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -369,6 +386,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -407,7 +427,7 @@ func (d *PikPak) refreshCaptchaToken(action string, metas map[string]string) err
|
||||
return errors.New(e.Error())
|
||||
}
|
||||
|
||||
if resp.Url != "" {
|
||||
if resp.Url != "" && !d.Addition.SkipVerification {
|
||||
return fmt.Errorf(`need verify: <a target="_blank" href="%s">Click Here</a>`, resp.Url)
|
||||
}
|
||||
|
||||
@@ -503,9 +523,11 @@ 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++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if utils.IsCanceled(ctx) {
|
||||
err = ctx.Err()
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
errCh <- errors.Wrap(err, "ossToken 过期")
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type Strm struct {
|
||||
|
||||
supportSuffix map[string]struct{}
|
||||
downloadSuffix map[string]struct{}
|
||||
minSizeBytes int64
|
||||
}
|
||||
|
||||
func (d *Strm) Config() driver.Config {
|
||||
@@ -120,6 +121,7 @@ func (d *Strm) Init(ctx context.Context) error {
|
||||
if len(d.SaveLocalMode) == 0 {
|
||||
d.SaveLocalMode = SaveLocalInsertMode
|
||||
}
|
||||
d.minSizeBytes = d.MinFileSize * 1024 * 1024
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+65
-47
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
stdpath "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
@@ -26,6 +27,10 @@ func UpdateLocalStrm(ctx context.Context, path string, objs []model.Obj) {
|
||||
updateLocal := func(driver *Strm, basePath string, objs []model.Obj) {
|
||||
relParent := strings.TrimPrefix(basePath, utils.GetActualMountPath(driver.MountPath))
|
||||
localParentPath := stdpath.Join(driver.SaveStrmLocalPath, relParent)
|
||||
if err := createLocalDirectory(localParentPath); err != nil {
|
||||
log.Warnf("failed to create local strm directory %s: %v", localParentPath, err)
|
||||
return
|
||||
}
|
||||
for _, obj := range objs {
|
||||
localPath := stdpath.Join(localParentPath, obj.GetName())
|
||||
generateStrm(ctx, driver, obj, localPath)
|
||||
@@ -92,55 +97,68 @@ func RemoveStrm(dstPath string, d *Strm) {
|
||||
}
|
||||
|
||||
func generateStrm(ctx context.Context, driver *Strm, obj model.Obj, localPath string) {
|
||||
if !obj.IsDir() {
|
||||
if utils.Exists(localPath) && driver.SaveLocalMode == SaveLocalInsertMode {
|
||||
return
|
||||
}
|
||||
link, err := driver.Link(ctx, obj, model.LinkArgs{})
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to link: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer link.Close()
|
||||
size := link.ContentLength
|
||||
if size <= 0 {
|
||||
size = obj.GetSize()
|
||||
}
|
||||
rrf, err := stream.GetRangeReaderFromLink(size, link)
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to get range reader: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
rc, err := rrf.RangeRead(ctx, http_range.Range{Length: -1})
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to read range: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
same, err := isSameContent(localPath, size, rc)
|
||||
if err != nil {
|
||||
log.Warnf("failed to compare content of obj %s: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
if same {
|
||||
return
|
||||
}
|
||||
rc, err = rrf.RangeRead(ctx, http_range.Range{Length: -1})
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to reread range: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
file, err := utils.CreateNestedFile(localPath)
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to create local file: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := utils.CopyWithBuffer(file, rc); err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: copy failed: %v", localPath, err)
|
||||
if obj.IsDir() {
|
||||
if err := createLocalDirectory(localPath); err != nil {
|
||||
log.Warnf("failed to create local strm directory %s: %v", localPath, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if utils.Exists(localPath) && driver.SaveLocalMode == SaveLocalInsertMode {
|
||||
return
|
||||
}
|
||||
link, err := driver.Link(ctx, obj, model.LinkArgs{})
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to link: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer link.Close()
|
||||
size := link.ContentLength
|
||||
if size <= 0 {
|
||||
size = obj.GetSize()
|
||||
}
|
||||
rrf, err := stream.GetRangeReaderFromLink(size, link)
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to get range reader: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
rc, err := rrf.RangeRead(ctx, http_range.Range{Length: -1})
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to read range: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
same, err := isSameContent(localPath, size, rc)
|
||||
if err != nil {
|
||||
log.Warnf("failed to compare content of obj %s: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
if same {
|
||||
return
|
||||
}
|
||||
rc, err = rrf.RangeRead(ctx, http_range.Range{Length: -1})
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to reread range: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
if err := createLocalDirectory(filepath.Dir(localPath)); err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to create parent directory: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
file, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: failed to create local file: %v", localPath, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := utils.CopyWithBuffer(file, rc); err != nil {
|
||||
log.Warnf("failed to generate strm of obj %s: copy failed: %v", localPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createLocalDirectory(path string) error {
|
||||
return os.MkdirAll(path, 0o777)
|
||||
}
|
||||
|
||||
func isSameContent(localPath string, size int64, rc io.Reader) (bool, error) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package strm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateLocalDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "library", "movie")
|
||||
if err := createLocalDirectory(nested); err != nil {
|
||||
t.Fatalf("createLocalDirectory() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(nested); err != nil {
|
||||
t.Fatalf("stat created directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLocalDirectoryPreservesExistingPermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("directory permission bits are not supported on Windows")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "library", "movie")
|
||||
if err := os.MkdirAll(nested, 0o700); err != nil {
|
||||
t.Fatalf("create existing directory: %v", err)
|
||||
}
|
||||
if err := createLocalDirectory(nested); err != nil {
|
||||
t.Fatalf("createLocalDirectory() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(nested)
|
||||
if err != nil {
|
||||
t.Fatalf("stat existing directory: %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o700 {
|
||||
t.Errorf("existing directory permissions = %#o, want %#o", got, 0o700)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package strm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateLocalDirectoryRespectsUmask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "library", "movie")
|
||||
|
||||
originalUmask := syscall.Umask(0o027)
|
||||
defer syscall.Umask(originalUmask)
|
||||
|
||||
if err := createLocalDirectory(nested); err != nil {
|
||||
t.Fatalf("createLocalDirectory() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(nested)
|
||||
if err != nil {
|
||||
t.Fatalf("stat created directory: %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o750 {
|
||||
t.Errorf("created directory permissions = %#o, want %#o", got, 0o750)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Addition struct {
|
||||
PathPrefix string `json:"PathPrefix" type:"text" required:"false" default:"/d" help:"Path prefix"`
|
||||
DownloadFileTypes string `json:"downloadFileTypes" type:"text" default:"ass,srt,vtt,sub,strm" required:"false" help:"Files need to download with strm (usally subtitles)"`
|
||||
FilterFileTypes string `json:"filterFileTypes" type:"text" default:"mp4,mkv,flv,avi,wmv,ts,rmvb,webm,mp3,flac,aac,wav,ogg,m4a,wma,alac" required:"false" help:"Supports suffix name of strm file"`
|
||||
MinFileSize int64 `json:"minFileSize" type:"number" required:"false" default:"0" help:"Filter out files smaller than this size (unit: MB, 0 to disable)"`
|
||||
EncodePath bool `json:"encodePath" default:"true" required:"true" help:"encode the path in the strm file"`
|
||||
WithoutUrl bool `json:"withoutUrl" default:"false" help:"strm file content without URL prefix"`
|
||||
WithSign bool `json:"withSign" default:"false"`
|
||||
|
||||
@@ -73,6 +73,9 @@ func (d *Strm) convert2strmObjs(ctx context.Context, reqPath string, objs []mode
|
||||
if _, ok := d.downloadSuffix[ext]; ok {
|
||||
size = obj.GetSize()
|
||||
} else if _, ok := d.supportSuffix[ext]; ok {
|
||||
if d.minSizeBytes > 0 && obj.GetSize() < d.minSizeBytes {
|
||||
continue
|
||||
}
|
||||
id = "strm"
|
||||
name = strings.TrimSuffix(name, sourceExt) + "strm"
|
||||
size = int64(len(d.getLink(ctx, path)))
|
||||
|
||||
@@ -55,10 +55,18 @@ func (d *Teldrive) Drop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (d *Teldrive) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
dirPath := dir.GetPath()
|
||||
if dirPath == "" {
|
||||
dirPath = d.GetRootPath()
|
||||
}
|
||||
if dirPath == "" {
|
||||
dirPath = "/"
|
||||
}
|
||||
|
||||
var firstResp ListResp
|
||||
err := d.request(http.MethodGet, "/api/files", func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"path": dir.GetPath(),
|
||||
"path": dirPath,
|
||||
"limit": "500",
|
||||
"page": "1",
|
||||
})
|
||||
@@ -87,7 +95,7 @@ func (d *Teldrive) List(ctx context.Context, dir model.Obj, args model.ListArgs)
|
||||
var resp ListResp
|
||||
err := d.request(http.MethodGet, "/api/files", func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"path": dir.GetPath(),
|
||||
"path": dirPath,
|
||||
"limit": "500",
|
||||
"page": strconv.Itoa(page),
|
||||
})
|
||||
@@ -114,7 +122,7 @@ func (d *Teldrive) List(ctx context.Context, dir model.Obj, args model.ListArgs)
|
||||
|
||||
return utils.SliceConvert(allItems, func(src Object) (model.Obj, error) {
|
||||
return &model.Object{
|
||||
Path: path.Join(dir.GetPath(), src.Name),
|
||||
Path: path.Join(dirPath, src.Name),
|
||||
ID: src.ID,
|
||||
Name: src.Name,
|
||||
Size: func() int64 {
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -36,3 +38,44 @@ func TestListEmptyDir(t *testing.T) {
|
||||
t.Fatalf("expected no entries for an empty dir, got %d", len(objs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRootUsesConfiguredRootPath(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
paths []string
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
paths = append(paths, r.URL.Query().Get("path"))
|
||||
mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"items":[{"id":"child","name":"child","type":"folder"}],"meta":{"count":1,"totalPages":1,"currentPage":1}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
oldClient := base.RestyClient
|
||||
base.RestyClient = resty.New()
|
||||
defer func() { base.RestyClient = oldClient }()
|
||||
|
||||
d := &Teldrive{
|
||||
Addition: Addition{
|
||||
RootPath: driver.RootPath{RootFolderPath: "/configured-root"},
|
||||
},
|
||||
}
|
||||
d.Address = srv.URL
|
||||
|
||||
objs, err := d.List(context.Background(), &model.Object{}, model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(paths) != 1 || paths[0] != "/configured-root" {
|
||||
t.Fatalf("expected request path %q, got %q", "/configured-root", paths)
|
||||
}
|
||||
if len(objs) != 1 || objs[0].GetPath() != "/configured-root/child" {
|
||||
t.Fatalf("expected child path %q, got %#v", "/configured-root/child", objs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
module github.com/OpenListTeam/OpenList/v4
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.4
|
||||
toolchain go1.27.1
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0
|
||||
github.com/KarpelesLab/reflink v1.0.2
|
||||
github.com/KirCute/zip v1.0.1
|
||||
github.com/OpenListTeam/go-cache v0.1.0
|
||||
github.com/OpenListTeam/gofakes3 v0.8.2-0.20260911142347-cd3c030a83b4
|
||||
github.com/OpenListTeam/sftpd-openlist v1.0.1
|
||||
github.com/OpenListTeam/tache v0.2.2
|
||||
github.com/OpenListTeam/times v0.1.0
|
||||
github.com/OpenListTeam/wopan-sdk-go v0.1.5
|
||||
github.com/ProtonMail/go-crypto v1.4.1
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.10.0
|
||||
github.com/SheltonZhu/115driver v1.3.4
|
||||
github.com/SheltonZhu/115driver v1.3.5
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
||||
github.com/antchfx/htmlquery v1.3.6
|
||||
github.com/antchfx/xpath v1.3.6
|
||||
github.com/antchfx/xpath v1.3.8
|
||||
github.com/avast/retry-go v3.0.0+incompatible
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/blevesearch/bleve/v2 v2.5.2
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||
github.com/blevesearch/bleve/v2 v2.6.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/caarlos0/env/v9 v9.0.0
|
||||
github.com/charmbracelet/bubbles v0.21.1
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8
|
||||
github.com/coreos/go-oidc v2.3.0+incompatible
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20260803221621-0b399b9d036c
|
||||
github.com/coreos/go-oidc v2.5.0+incompatible
|
||||
github.com/deckarep/golang-set/v2 v2.9.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
@@ -43,7 +44,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-resty/resty/v2 v2.17.2
|
||||
github.com/go-webauthn/webauthn v0.13.4
|
||||
github.com/go-webauthn/webauthn v0.18.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -51,8 +52,7 @@ require (
|
||||
github.com/hekmon/transmissionrpc/v3 v3.0.0
|
||||
github.com/henrybear327/go-proton-api v1.0.0
|
||||
github.com/ipfs/go-ipfs-api v0.7.0
|
||||
github.com/itsHenry35/gofakes3 v0.0.8
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb
|
||||
github.com/jlaffaye/ftp v0.2.4
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/kdomanski/iso9660 v0.4.0
|
||||
github.com/maruel/natural v1.3.0
|
||||
@@ -61,45 +61,46 @@ require (
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/ncw/swift/v2 v2.0.5
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/pkg/sftp v1.13.11
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/quic-go/quic-go v0.60.0
|
||||
github.com/rclone/rclone v1.74.4
|
||||
github.com/shirou/gopsutil/v4 v4.26.6
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/quic-go/quic-go v0.63.0
|
||||
github.com/rclone/rclone v1.75.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.8
|
||||
github.com/sirupsen/logrus v1.10.2
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20260717075258-c6acd6a5bd04
|
||||
github.com/tchap/go-patricia/v2 v2.3.3
|
||||
github.com/u2takey/ffmpeg-go v0.5.0
|
||||
github.com/upyun/go-sdk/v3 v3.0.4
|
||||
github.com/winfsp/cgofuse v1.6.1-0.20260126094232-f2c4fccdb286
|
||||
github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/appengine v1.6.8
|
||||
gopkg.in/ldap.v3 v3.1.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.2
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
|
||||
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
|
||||
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
|
||||
github.com/ProtonMail/go-srp v0.0.7 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.12.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/blevesearch/zapx/v17 v17.2.3 // indirect
|
||||
github.com/bradenaw/juniper v0.15.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
@@ -111,11 +112,12 @@ require (
|
||||
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
|
||||
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
|
||||
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 // indirect
|
||||
github.com/geoffgarside/ber v1.2.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
@@ -127,20 +129,23 @@ require (
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/lanrat/extsort v1.4.2 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.2.0 // indirect
|
||||
github.com/minio/xxml v0.0.3 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/relvacode/iso8601 v1.7.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
|
||||
gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
@@ -152,15 +157,15 @@ require (
|
||||
github.com/OpenListTeam/115-sdk-go v0.2.6
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/blevesearch/go-faiss v1.0.25 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
|
||||
github.com/blevesearch/go-faiss v1.1.5 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.3.4 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.5
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.5 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudflare/circl v1.6.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
@@ -174,7 +179,7 @@ require (
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/nwaples/rardecode/v2 v2.2.5
|
||||
github.com/nwaples/rardecode/v2 v2.4.1
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
@@ -195,21 +200,21 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
|
||||
github.com/blevesearch/geo v0.2.3 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.4.1 // indirect
|
||||
github.com/blevesearch/geo v0.2.6 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/gtreap v0.1.1 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.4 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect
|
||||
github.com/blevesearch/mmap-go v1.2.0 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.10 // indirect
|
||||
github.com/blevesearch/segment v0.9.1 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
|
||||
github.com/blevesearch/vellum v1.1.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
|
||||
github.com/blevesearch/vellum v1.2.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.3 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -217,56 +222,56 @@ require (
|
||||
github.com/crackcomm/go-gitignore v0.0.0-20170627025303-887ab5e44cc3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-chi/chi/v5 v5.3.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-webauthn/x v0.3.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/henrybear327/Proton-API-Bridge v1.0.0
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/ipfs/go-cid v0.5.0
|
||||
github.com/ipfs/go-cid v0.6.2
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||
github.com/libp2p/go-flow-metrics v0.1.0 // indirect
|
||||
github.com/libp2p/go-libp2p v0.27.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/mr-tron/base58 v1.3.0 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
@@ -274,11 +279,11 @@ require (
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr v0.9.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.2.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.3.0 // indirect
|
||||
github.com/multiformats/go-multicodec v0.9.0 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-multistream v0.4.1 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
@@ -286,8 +291,8 @@ require (
|
||||
github.com/pquerna/cachecontrol v0.1.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/common v0.70.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/rfjakob/eme v1.2.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
|
||||
@@ -295,22 +300,22 @@ require (
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/u2takey/go-utils v0.3.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
golang.org/x/text v0.40.0
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/grpc v1.82.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d // indirect
|
||||
google.golang.org/grpc v1.85.0-dev
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -5,22 +5,32 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZYLBR2kBz5C8Tg0fw5w5Y7meRXWI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 h1:irsmOWwkp0KCTTNS5e2hdFeIvSQClQo2No3IaNmL3Vw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0/go.mod h1:GWcBkQj3MqN7ozHKLaCCAuNLiXoIGv2RtanfAwSjY/Y=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
@@ -41,6 +51,8 @@ github.com/OpenListTeam/115-sdk-go v0.2.6 h1:ehXyStvncvn4qRBuknor3kyGZtUmHc0+stj
|
||||
github.com/OpenListTeam/115-sdk-go v0.2.6/go.mod h1:cfvitk2lwe6036iNi2h+iNxwxWDifKZsSvNtrur5BqU=
|
||||
github.com/OpenListTeam/go-cache v0.1.0 h1:eV2+FCP+rt+E4OCJqLUW7wGccWZNJMV0NNkh+uChbAI=
|
||||
github.com/OpenListTeam/go-cache v0.1.0/go.mod h1:AHWjKhNK3LE4rorVdKyEALDHoeMnP8SjiNyfVlB+Pz4=
|
||||
github.com/OpenListTeam/gofakes3 v0.8.2-0.20260911142347-cd3c030a83b4 h1:Zy7/qg6aCS0OF/FPIoJh9/d0IgcIxpWRvn79ACm2R/Y=
|
||||
github.com/OpenListTeam/gofakes3 v0.8.2-0.20260911142347-cd3c030a83b4/go.mod h1:mS9Ywbo6aId6BrRzeYjOIOpK0QDVnMoKOIb0hpaQZ3U=
|
||||
github.com/OpenListTeam/gsync v0.1.0 h1:ywzGybOvA3lW8K1BUjKZ2IUlT2FSlzPO4DOazfYXjcs=
|
||||
github.com/OpenListTeam/gsync v0.1.0/go.mod h1:h/Rvv9aX/6CdW/7B8di3xK3xNV8dUg45Fehrd/ksZ9s=
|
||||
github.com/OpenListTeam/reflink v0.0.0-20260701021214-78760eaeafef h1:67uGHancMF/abMrnkc8abVUWQiG73Wk5d8CKt3RzkFo=
|
||||
@@ -71,12 +83,18 @@ github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiU
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
|
||||
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
github.com/SheltonZhu/115driver v1.3.4 h1:joUFKN591n7KiTEGnBw0ZHjPzhl3G1PpZrwv2YHhoC8=
|
||||
github.com/SheltonZhu/115driver v1.3.4/go.mod h1:OujS7azslg1/bn85sPSHnNsp4/WBI9/TiijtZL9kuSQ=
|
||||
github.com/SheltonZhu/115driver v1.3.5 h1:NlJA61RskdwHhIZyg4519696lCk1n4aid0lKARsMwFg=
|
||||
github.com/SheltonZhu/115driver v1.3.5/go.mod h1:ag0B8Vs6TPFQjTNFLVVxA7dINPt4q+WR47+JIPJhmTk=
|
||||
github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0=
|
||||
github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
@@ -92,10 +110,14 @@ github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtn
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
|
||||
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.8 h1:RQlkLaJDKk1Ew1H6CUPUTKM+IQxm+6HTyOgcrfqOU9c=
|
||||
github.com/antchfx/xpath v1.3.8/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
@@ -146,20 +168,42 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/blevesearch/bleve/v2 v2.5.2 h1:Ab0r0MODV2C5A6BEL87GqLBySqp/s9xFgceCju6BQk8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.2/go.mod h1:5Dj6dUQxZM6aqYT3eutTD/GpWKGFSsV8f7LDidFbwXo=
|
||||
github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4=
|
||||
github.com/blevesearch/bleve/v2 v2.6.0/go.mod h1:gLmI8lWgHgrIYf7UpUX7JISI1CaqC6VScu46mHThuAY=
|
||||
github.com/blevesearch/bleve/v2 v2.6.1 h1:47vLskRTqxvQEtxVPYHjf5KpOgzD2msslXFjvUQCgWQ=
|
||||
github.com/blevesearch/bleve/v2 v2.6.1/go.mod h1:Dvvx6ZoEBTOj6RSzfk0lEz0wce/qhe2yOUubXeuzd2c=
|
||||
github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y=
|
||||
github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
|
||||
github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4=
|
||||
github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
|
||||
github.com/blevesearch/bleve_index_api v1.4.1 h1:CYIyecFlI+/RYjzUm+NmDjYbSvk870Bb7f+Vl4b12q8=
|
||||
github.com/blevesearch/bleve_index_api v1.4.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
|
||||
github.com/blevesearch/geo v0.2.3 h1:K9/vbGI9ehlXdxjxDRJtoAMt7zGAsMIzc6n8zWcwnhg=
|
||||
github.com/blevesearch/geo v0.2.3/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
|
||||
github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
|
||||
github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
|
||||
github.com/blevesearch/geo v0.2.6 h1:7K1oyQKYlauC+mJuo2AfNPyjN/4mihEoJMfyClVH1Mo=
|
||||
github.com/blevesearch/geo v0.2.6/go.mod h1:6qzVUiB4BK47QkSZcRqiXEP2W3EeXuzM5XFTF8AdZ8A=
|
||||
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
|
||||
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/go-faiss v1.1.0 h1:xM7Jc0ZUCv5lssG9Ohj3Jv0SdTpxcUABU1dDt9XVsc4=
|
||||
github.com/blevesearch/go-faiss v1.1.0/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/go-faiss v1.1.5 h1:/IU5lkOahH9Ghfk9n3F6N0XD7PYVXZJWmNDc9TtXuco=
|
||||
github.com/blevesearch/go-faiss v1.1.5/go.mod h1:w3W9AiWsFRGVaMG+/cmJi7iHEAuGyC6blsgO1EzCK/M=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
|
||||
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
|
||||
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
|
||||
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
|
||||
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
|
||||
github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
|
||||
github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.10 h1:Yqk0XD1mE0fDZAJXTjawJ8If/85JxnLd8v5vG/jWE/s=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.10 h1:C3873+iWZ0YJM2ijaSHhJJzSvD4x1k+5UaQdGygZVhM=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.10/go.mod h1:WUUkAocbkDlNK/kgAE13NvS9oxe+u618mYZ8sOvcCc4=
|
||||
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
|
||||
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
|
||||
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
|
||||
@@ -168,20 +212,40 @@ github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMG
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
|
||||
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
|
||||
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
|
||||
github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
|
||||
github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
|
||||
github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
|
||||
github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
|
||||
github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
|
||||
github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
|
||||
github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
|
||||
github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
|
||||
github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
|
||||
github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
|
||||
github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
|
||||
github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
|
||||
github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww=
|
||||
github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs=
|
||||
github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
|
||||
github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
|
||||
github.com/blevesearch/zapx/v17 v17.1.2 h1:avbOk2igaASNoiy0BE/jPgcxAnRI2PGeydeP4hg7Ikk=
|
||||
github.com/blevesearch/zapx/v17 v17.1.2/go.mod h1:WQObxKrqUX7cd0G1GMvDfc/bmZzQvoy7APOPimx7DiI=
|
||||
github.com/blevesearch/zapx/v17 v17.2.3 h1:UYYJPAt5b2tVxldx5h0jmv23RMsg8/UZKFVya7v92po=
|
||||
github.com/blevesearch/zapx/v17 v17.2.3/go.mod h1:r7mb4QWbDQSkbAnOjCb9iCfkcrzajB4yBdJpuBIo/fE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng=
|
||||
@@ -238,14 +302,20 @@ github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYs
|
||||
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
|
||||
github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8 h1:+KC2I+emnT6ccWmo7IHbtchGc/vFqAxUuovo2eUgMuM=
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8/go.mod h1:1pQXB0vAlzRlqcY7LYKOOZMw0wKfJPFxTLsJRF2Gswo=
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20260803221621-0b399b9d036c h1:7VByb9X2X3LXbk89eMavoQO9uD5dcAjY6uPZhAR9Yso=
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20260803221621-0b399b9d036c/go.mod h1:1pQXB0vAlzRlqcY7LYKOOZMw0wKfJPFxTLsJRF2Gswo=
|
||||
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs=
|
||||
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0=
|
||||
github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
|
||||
github.com/coreos/go-oidc v2.5.0+incompatible h1:6W0vGJR3Tu0r0PwfmjOrRZSlfxeEln8dsejt3ZWIvwo=
|
||||
github.com/coreos/go-oidc v2.5.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
|
||||
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
|
||||
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
@@ -280,10 +350,14 @@ github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564 h1:I6KUy4CI6hHjqnyJL
|
||||
github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564/go.mod h1:yekO+3ZShy19S+bsmnERmznGy9Rfg6dWWWpiGJjNAz8=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg=
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI=
|
||||
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fclairamb/ftpserverlib v0.26.1-0.20250709223522-4a925d79caf6 h1:q1b+gv6AG2TDPN+f0QAkbRrAvJ3ZosnwRLTKNxSXlaA=
|
||||
@@ -299,6 +373,8 @@ github.com/foxxorcat/weiyun-sdk-go v0.1.4/go.mod h1:TPxzN0d2PahweUEHlOBWlwZSA+rE
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
|
||||
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -319,6 +395,8 @@ github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
|
||||
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
|
||||
@@ -341,16 +419,28 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA=
|
||||
github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE=
|
||||
github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -370,12 +460,16 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20230405160723-4a4c7d95572b h1:Qcx5LM0fSiks9uCyFZwDBUasd3lxd1RM0GYpL+Li5o4=
|
||||
github.com/google/pprof v0.0.0-20230405160723-4a4c7d95572b/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk=
|
||||
@@ -427,16 +521,18 @@ github.com/ipfs/boxo v0.12.0 h1:AXHg/1ONZdRQHQLgG5JHsSC3XoE4DjCAMgK+asZvUcQ=
|
||||
github.com/ipfs/boxo v0.12.0/go.mod h1:xAnfiU6PtxWCnRqu7dcXQ10bB5/kvI1kXRotuGqGBhg=
|
||||
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
|
||||
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
|
||||
github.com/ipfs/go-cid v0.6.2 h1:VuGwJd+KJTaMJ4S4d5EEf9SXc17YUblS5axCbocn9YE=
|
||||
github.com/ipfs/go-cid v0.6.2/go.mod h1:Xhwg8NzHeK9xPCEZkCw4idzPiuNMpX3fARuI5Iwj1Lo=
|
||||
github.com/ipfs/go-ipfs-api v0.7.0 h1:CMBNCUl0b45coC+lQCXEVpMhwoqjiaCwUIrM+coYW2Q=
|
||||
github.com/ipfs/go-ipfs-api v0.7.0/go.mod h1:AIxsTNB0+ZhkqIfTZpdZ0VR/cpX5zrXjATa3prSay3g=
|
||||
github.com/itsHenry35/gofakes3 v0.0.8 h1:1AgOl04IgoUV5r/WSK7ycnvwfpgharYLfVTmnzk5miw=
|
||||
github.com/itsHenry35/gofakes3 v0.0.8/go.mod h1:gQwOJ7LoH5QSpCVmjzC6oKp+MS71utLS7GHtonsvD0c=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -459,6 +555,8 @@ github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI=
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb h1:6vkM8gO+zFV2m21QzGYyUSq5TP0VQgP2Xz3UQyCN2kI=
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb/go.mod h1:H1+whwD0Qe3YOunlXIWhh3rlvzW5cZfkMDYGQPg+KAM=
|
||||
github.com/jlaffaye/ftp v0.2.4 h1:JqI85DdkfZj8ntaHk8W9U2SC3jNfiPUU70+wtIWmlfE=
|
||||
github.com/jlaffaye/ftp v0.2.4/go.mod h1:Y1ZnkzxownGIuX7xQ1mQzzkZ21+DbjVIyeKL/V+IIz4=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
@@ -481,6 +579,8 @@ github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgo
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
@@ -505,28 +605,40 @@ github.com/libp2p/go-libp2p v0.27.8 h1:IX5x/4yKwyPQeVS2AXHZ3J4YATM9oHBGH1gBc23jB
|
||||
github.com/libp2p/go-libp2p v0.27.8/go.mod h1:eCFFtd0s5i/EVKR7+5Ki8bM7qwkNW3TPTTSSW9sz8NE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c=
|
||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk=
|
||||
github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/meilisearch/meilisearch-go v0.32.0 h1:cWcycpONSH3VLTZ5npUl1O5aXPkNM0vUx6bywnYqGbE=
|
||||
@@ -539,6 +651,8 @@ github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZz
|
||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
|
||||
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/minio/minlz v1.2.0 h1:6IOBuiHg04QxvbFfgFLT/9sMaO/UhL7S+ApW1mK8q5A=
|
||||
github.com/minio/minlz v1.2.0/go.mod h1:Ls9H7nlkASeCcdl5thjVD5Eraj6z+zGa7xtq57jIKD4=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/minio/xxml v0.0.3 h1:ZIpPQpfyG5uZQnqqC0LZuWtPk/WT8G/qkxvO6jb7zMU=
|
||||
@@ -556,6 +670,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
||||
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
@@ -572,6 +688,8 @@ github.com/multiformats/go-multiaddr v0.9.0 h1:3h4V1LHIk5w4hJHekMKWALPXErDfz/sgg
|
||||
github.com/multiformats/go-multiaddr v0.9.0/go.mod h1:mI67Lb1EeTOYb8GQfL/7wpIZwc46ElrvzhYnoJOmTT0=
|
||||
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
||||
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
||||
github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68=
|
||||
github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI=
|
||||
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
|
||||
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
|
||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||
@@ -580,6 +698,8 @@ github.com/multiformats/go-multistream v0.4.1 h1:rFy0Iiyn3YT0asivDUIR05leAdwZq3d
|
||||
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
|
||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||
github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI=
|
||||
github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||
@@ -588,9 +708,15 @@ github.com/ncw/swift/v2 v2.0.5 h1:9o5Gsd7bInAFEqsGPcaUdsboMbqf8lnNtxqWKFT9iz8=
|
||||
github.com/ncw/swift/v2 v2.0.5/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/nwaples/rardecode/v2 v2.3.0 h1:CtgyxWm8ClLcSh1u4M58fOz6lmeb/j4V7KpaEi/6UtM=
|
||||
github.com/nwaples/rardecode/v2 v2.3.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/nwaples/rardecode/v2 v2.4.1 h1:F7zNW2LdAuuBThHWXQaiFUGVD/sef299NfWSB1nHAl4=
|
||||
github.com/nwaples/rardecode/v2 v2.4.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -600,6 +726,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
|
||||
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
|
||||
github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA=
|
||||
github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -621,20 +749,32 @@ github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQP
|
||||
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
|
||||
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
|
||||
github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A=
|
||||
github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
|
||||
github.com/rclone/rclone v1.70.3 h1:rg/WNh4DmSVZyKP2tHZ4lAaWEyMi7h/F0r7smOMA3IE=
|
||||
github.com/rclone/rclone v1.70.3/go.mod h1:nLyN+hpxAsQn9Rgt5kM774lcRDad82x/KqQeBZ83cMo=
|
||||
github.com/rclone/rclone v1.74.4 h1:/c6LMO2kPQjJa6a/PBFcIYJHDvfNOAtfDBJy44mGMpc=
|
||||
github.com/rclone/rclone v1.74.4/go.mod h1:M8GXX+n9vrP5HwMt0O6wxQcAjlUTUdSQugFrjXQI8kI=
|
||||
github.com/rclone/rclone v1.75.0 h1:3ARHem4jXWltvl+b0PvDAG8s6J/inHd5BRzfwMRb3W8=
|
||||
github.com/rclone/rclone v1.75.0/go.mod h1:PGLJUW/WSIJCysALqUcxmaCFyfMXUevf8CbuoOwsAdU=
|
||||
github.com/rclone/rclone v1.75.1 h1:kIxQcoDLj2Gke/gMSHK7OnxhX1Gu1cJBLP1kJZoaFp0=
|
||||
github.com/rclone/rclone v1.75.1/go.mod h1:4zmMjGatCkSJPRZDpo+7y3xOl8S29EMUyKvZop5mHr4=
|
||||
github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU=
|
||||
github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
|
||||
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
|
||||
@@ -659,8 +799,14 @@ github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df h1:S77Pf5fIG
|
||||
github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df/go.mod h1:dcuzJZ83w/SqN9k4eQqwKYMgmKWzg/KzJAURBhRL1tc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
|
||||
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
@@ -697,6 +843,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20260630094356-4de2f127f34a h1:VQ9CmpYeTgdFvg748N6tIOHBZn+hXjJe2xNuOJHW07E=
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20260630094356-4de2f127f34a/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo=
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20260717075258-c6acd6a5bd04 h1:s30A8dMuZ55lUOi5xUTh1hlfLqHpVBsRMfpk2iKCayk=
|
||||
@@ -705,10 +853,16 @@ github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 h1:6Y51mutOvRGRx6K
|
||||
github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543/go.mod h1:jpwqYA8KUVEvSUJHkCXsnBRJCSKP1BMa81QZ6kvRpow=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU=
|
||||
@@ -724,10 +878,6 @@ github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8
|
||||
github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8=
|
||||
github.com/upyun/go-sdk/v3 v3.0.4 h1:2DCJa/Yi7/3ZybT9UCPATSzvU3wpPPxhXinNlb1Hi8Q=
|
||||
github.com/upyun/go-sdk/v3 v3.0.4/go.mod h1:P/SnuuwhrIgAVRd/ZpzDWqCsBAf/oHg7UggbAxyZa0E=
|
||||
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
|
||||
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
|
||||
github.com/winfsp/cgofuse v1.6.1-0.20260126094232-f2c4fccdb286 h1:tw5GqRXqExB/xghPoPLtVujBe9w9Pg1G78tvXCJNJAA=
|
||||
github.com/winfsp/cgofuse v1.6.1-0.20260126094232-f2c4fccdb286/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
@@ -747,6 +897,8 @@ go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
|
||||
go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||
@@ -773,7 +925,10 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
|
||||
@@ -791,15 +946,27 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
|
||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -821,6 +988,10 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -855,6 +1026,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -867,6 +1040,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -881,6 +1056,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
@@ -896,6 +1073,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
@@ -906,8 +1085,14 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d h1:Jkpk39hlTZOIp3RbfvNX9R8Hv+Sw0X89nlU/xFOErsc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6 h1:HfjjkdGIa8u9sP9EW5WCygy0kQDuTI/Tax4j//t24Fo=
|
||||
google.golang.org/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
|
||||
google.golang.org/grpc v1.85.0-dev h1:HxkDyKIIZPpFnroC56tQv5gNuKTmVvi0t7TzOf5zt7g=
|
||||
google.golang.org/grpc v1.85.0-dev/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
@@ -935,8 +1120,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
|
||||
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
|
||||
@@ -6,13 +6,12 @@ import (
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/setting"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
func NewAuthnInstance(c *gin.Context) (*webauthn.WebAuthn, error) {
|
||||
siteUrl, err := url.Parse(common.GetApiUrl(c.Request.Context()))
|
||||
siteUrl, err := url.Parse(conf.GetApiUrl(c.Request.Context()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ func InitialSettings() []model.SettingItem {
|
||||
{Key: conf.SSODefaultDir, Value: "/", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
|
||||
{Key: conf.SSODefaultPermission, Value: "0", Type: conf.TypeNumber, Group: model.SSO, Flag: model.PRIVATE},
|
||||
{Key: conf.SSOCompatibilityMode, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PUBLIC},
|
||||
{Key: conf.SSOPostMessageOrigin, Value: "", Type: conf.TypeString, Group: model.SSO, Flag: model.PUBLIC},
|
||||
|
||||
// ldap settings
|
||||
{Key: conf.LdapLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.LDAP, Flag: model.PUBLIC},
|
||||
@@ -243,12 +244,15 @@ func InitialSettings() []model.SettingItem {
|
||||
{Key: conf.TaskOfflineDownloadTransferThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Transfer.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.TaskUploadThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Upload.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.TaskCopyThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Copy.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.TaskMoveThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Move.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.TaskDecompressDownloadThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Decompress.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.TaskDecompressUploadThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.DecompressUpload.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.StreamMaxClientDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.StreamMaxServerUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.MultipartEnabled, Value: "true", Type: conf.TypeBool, Group: model.TRAFFIC, Flag: model.PUBLIC},
|
||||
{Key: conf.MultipartChunkSize, Value: "10", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PUBLIC, Help: `chunk size of multipart upload in MB (positive integer), keep it under your CDN's request body limit; each active session buffers up to 8 chunks on the server's disk`},
|
||||
}
|
||||
additionalSettingItems := tool.Tools.Items()
|
||||
// 固定顺序
|
||||
|
||||
@@ -15,18 +15,28 @@ import (
|
||||
)
|
||||
|
||||
func initUser() {
|
||||
admin, err := op.GetAdmin()
|
||||
adminPassword := random.String(8)
|
||||
envpass := os.Getenv("OPENLIST_ADMIN_PASSWORD")
|
||||
if flags.Dev {
|
||||
adminPassword = "admin"
|
||||
} else if len(envpass) > 0 {
|
||||
adminPassword = envpass
|
||||
}
|
||||
initAdmin()
|
||||
initGuest()
|
||||
}
|
||||
|
||||
func initAdmin() {
|
||||
_, err := op.GetAdmin()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 系统尚未初始化:仅在开发模式或显式配置 OPENLIST_ADMIN_PASSWORD 时
|
||||
// 自动创建管理员;否则交由 Web 安装向导(POST /api/public/init/setup)完成。
|
||||
adminPassword := "admin"
|
||||
envpass := os.Getenv("OPENLIST_ADMIN_PASSWORD")
|
||||
if flags.Dev {
|
||||
adminPassword = "admin"
|
||||
} else if len(envpass) > 0 {
|
||||
adminPassword = envpass
|
||||
} else {
|
||||
// 未初始化:不自动创建管理员,等待 Web 安装向导。
|
||||
return
|
||||
}
|
||||
salt := random.String(16)
|
||||
admin = &model.User{
|
||||
admin := &model.User{
|
||||
Username: "admin",
|
||||
Salt: salt,
|
||||
PwdHash: model.TwoHashPwd(adminPassword, salt),
|
||||
@@ -47,7 +57,10 @@ func initUser() {
|
||||
utils.Log.Fatalf("[init user] Failed to get admin user: %v", err)
|
||||
}
|
||||
}
|
||||
_, err = op.GetGuest()
|
||||
}
|
||||
|
||||
func initGuest() {
|
||||
_, err := op.GetGuest()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
salt := random.String(16)
|
||||
|
||||
@@ -83,9 +83,10 @@ type Cors struct {
|
||||
}
|
||||
|
||||
type S3 struct {
|
||||
Enable bool `json:"enable" env:"ENABLE"`
|
||||
Port int `json:"port" env:"PORT"`
|
||||
SSL bool `json:"ssl" env:"SSL"`
|
||||
Enable bool `json:"enable" env:"ENABLE"`
|
||||
Port int `json:"port" env:"PORT"`
|
||||
SSL bool `json:"ssl" env:"SSL"`
|
||||
MultipartTTL string `json:"multipart_ttl" env:"MULTIPART_TTL"`
|
||||
}
|
||||
|
||||
type FTP struct {
|
||||
@@ -229,9 +230,10 @@ func DefaultConfig(dataDir string) *Config {
|
||||
AllowHeaders: []string{"*"},
|
||||
},
|
||||
S3: S3{
|
||||
Enable: false,
|
||||
Port: 5246,
|
||||
SSL: false,
|
||||
Enable: false,
|
||||
Port: 5246,
|
||||
SSL: false,
|
||||
MultipartTTL: "24h",
|
||||
},
|
||||
FTP: FTP{
|
||||
Enable: false,
|
||||
|
||||
@@ -95,6 +95,9 @@ const (
|
||||
// thunder_browser
|
||||
ThunderBrowserTempDir = "thunder_browser_temp_dir"
|
||||
|
||||
// guangyapan
|
||||
GuangYaPanTempDir = "guangyapan_temp_dir"
|
||||
|
||||
// single
|
||||
Token = "token"
|
||||
IndexProgress = "index_progress"
|
||||
@@ -114,6 +117,7 @@ const (
|
||||
SSODefaultDir = "sso_default_dir"
|
||||
SSODefaultPermission = "sso_default_permission"
|
||||
SSOCompatibilityMode = "sso_compatibility_mode"
|
||||
SSOPostMessageOrigin = "sso_postmessage_origin"
|
||||
|
||||
// ldap
|
||||
LdapLoginEnabled = "ldap_login_enabled"
|
||||
@@ -161,6 +165,8 @@ const (
|
||||
StreamMaxClientUploadSpeed = "max_client_upload_speed"
|
||||
StreamMaxServerDownloadSpeed = "max_server_download_speed"
|
||||
StreamMaxServerUploadSpeed = "max_server_upload_speed"
|
||||
MultipartEnabled = "multipart_enabled"
|
||||
MultipartChunkSize = "multipart_chunk_size"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package conf
|
||||
|
||||
import "context"
|
||||
|
||||
func GetApiUrl(ctx context.Context) string {
|
||||
api, _ := ctx.Value(ApiUrlKey).(string)
|
||||
return api
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package conf_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
)
|
||||
|
||||
func TestGetApiUrl(t *testing.T) {
|
||||
const want = "https://openlist.example"
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
want string
|
||||
}{
|
||||
{name: "present", ctx: context.WithValue(context.Background(), conf.ApiUrlKey, want), want: want},
|
||||
{name: "absent", ctx: context.Background()},
|
||||
{name: "wrong type", ctx: context.WithValue(context.Background(), conf.ApiUrlKey, 1)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := conf.GetApiUrl(tt.ctx); got != tt.want {
|
||||
t.Fatalf("origin = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@ func GetMetaByPath(path string) (*model.Meta, error) {
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func GetAllMetas() ([]model.Meta, error) {
|
||||
var metas []model.Meta
|
||||
if err := db.Find(&metas).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "failed get all metas")
|
||||
}
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
func GetMetaById(id uint) (*model.Meta, error) {
|
||||
var u model.Meta
|
||||
if err := db.First(&u, id).Error; err != nil {
|
||||
|
||||
@@ -35,7 +35,7 @@ func DeleteSearchNodesByParent(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir, name := stdpath.Split(path)
|
||||
dir, name := stdpath.Dir(path), stdpath.Base(path)
|
||||
return db.Where(fmt.Sprintf("%s = ? AND %s = ?",
|
||||
columnName("parent"), columnName("name")),
|
||||
dir, name).Delete(&model.SearchNode{}).Error
|
||||
|
||||
@@ -18,6 +18,7 @@ var (
|
||||
StorageNotInit = errors.New("storage not init")
|
||||
StreamIncomplete = errors.New("upload/download stream incomplete, possible network issue")
|
||||
StreamPeekFail = errors.New("StreamPeekFail")
|
||||
TemporaryCapacity = errors.New("temporary capacity unavailable")
|
||||
|
||||
UnknownArchiveFormat = errors.New("unknown archive format")
|
||||
WrongArchivePassword = errors.New("wrong archive password")
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/task"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/task_group"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/common"
|
||||
"github.com/OpenListTeam/tache"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -415,7 +414,7 @@ func archiveDecompress(ctx context.Context, srcObjPath, dstDirPath string, args
|
||||
return nil, err
|
||||
} else {
|
||||
tsk.Creator, _ = ctx.Value(conf.UserKey).(*model.User)
|
||||
tsk.ApiUrl = common.GetApiUrl(ctx)
|
||||
tsk.ApiUrl = conf.GetApiUrl(ctx)
|
||||
ArchiveDownloadTaskManager.Add(tsk)
|
||||
return tsk, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/task"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/task_group"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/common"
|
||||
"github.com/OpenListTeam/tache"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -166,7 +165,7 @@ func transfer(ctx context.Context, taskType taskType, srcObjPath, dstDirPath str
|
||||
}
|
||||
|
||||
t.Creator, _ = ctx.Value(conf.UserKey).(*model.User)
|
||||
t.ApiUrl = common.GetApiUrl(ctx)
|
||||
t.ApiUrl = conf.GetApiUrl(ctx)
|
||||
if taskType == copy || taskType == merge {
|
||||
CopyTaskManager.Add(t)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/tache"
|
||||
)
|
||||
|
||||
func TestMigratedCopyTaskRecoversNativeFields(t *testing.T) {
|
||||
previousConf := conf.Conf
|
||||
conf.Conf = &conf.Config{}
|
||||
t.Cleanup(func() { conf.Conf = previousConf })
|
||||
|
||||
raw := []byte(`{
|
||||
"id":"task-one",
|
||||
"state":0,
|
||||
"retry":0,
|
||||
"max_retry":0,
|
||||
"Creator":{"id":1,"username":"admin","password":"","base_path":"/","role":2,"disabled":false,"permission":511,"sso_id":"","allow_ldap":true},
|
||||
"start_time":"2026-08-11T01:00:00Z",
|
||||
"end_time":"2026-08-11T01:01:00Z",
|
||||
"TotalBytes":42,
|
||||
"ApiUrl":"http://openlist.test:5244",
|
||||
"src_path":"/folder/file",
|
||||
"dst_path":"/backup",
|
||||
"src_storage_mp":"/source",
|
||||
"dst_storage_mp":"/target",
|
||||
"TaskType":0
|
||||
}`)
|
||||
|
||||
var task FileTransferTask
|
||||
if err := json.Unmarshal(raw, &task); err != nil {
|
||||
t.Fatalf("unmarshal migrated task: %v", err)
|
||||
}
|
||||
if task.GetID() != "task-one" || task.GetState() != tache.StatePending {
|
||||
t.Fatalf("unexpected base fields: id=%q state=%d", task.GetID(), task.GetState())
|
||||
}
|
||||
if task.GetCreator() == nil || task.GetCreator().Username != "admin" {
|
||||
t.Fatal("creator was not recovered")
|
||||
}
|
||||
if task.GetStartTime() == nil || task.GetEndTime() == nil {
|
||||
t.Fatal("task timestamps were not recovered")
|
||||
}
|
||||
if task.TaskType != copy {
|
||||
t.Fatalf("unexpected task type: %d", task.TaskType)
|
||||
}
|
||||
if task.SrcActualPath != "/folder/file" || task.DstActualPath != "/backup" {
|
||||
t.Fatal("copy paths were not recovered")
|
||||
}
|
||||
|
||||
_, maxRetry := task.GetRetry()
|
||||
if maxRetry != 0 {
|
||||
t.Fatalf("migration must defer retry initialization, got %d", maxRetry)
|
||||
}
|
||||
task.SetRetry(0, 2)
|
||||
_, maxRetry = task.GetRetry()
|
||||
if maxRetry != 2 {
|
||||
t.Fatalf("retry initialization failed, got %d", maxRetry)
|
||||
}
|
||||
if task.groupID != "/target/backup" {
|
||||
t.Fatalf("task group was not rebuilt: %q", task.groupID)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/common"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ func link(ctx context.Context, path string, args model.LinkArgs) (*model.Link, m
|
||||
return nil, nil, errors.WithMessage(err, "failed link")
|
||||
}
|
||||
if l.URL != "" && !strings.HasPrefix(l.URL, "http://") && !strings.HasPrefix(l.URL, "https://") {
|
||||
l.URL = common.GetApiUrl(ctx) + l.URL
|
||||
l.URL = conf.GetApiUrl(ctx) + l.URL
|
||||
}
|
||||
return l, obj, nil
|
||||
}
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/common"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
@@ -81,7 +80,7 @@ func putAsTask(ctx context.Context, dstDirPath string, file model.FileStreamer)
|
||||
t := &UploadTask{
|
||||
TaskExtension: task.TaskExtension{
|
||||
Creator: taskCreator,
|
||||
ApiUrl: common.GetApiUrl(ctx),
|
||||
ApiUrl: conf.GetApiUrl(ctx),
|
||||
},
|
||||
storage: storage,
|
||||
dstDirActualPath: dstDirActualPath,
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package fuse
|
||||
|
||||
import "github.com/winfsp/cgofuse/fuse"
|
||||
|
||||
type Fs struct {
|
||||
RootFolder string
|
||||
fuse.FileSystemBase
|
||||
}
|
||||
|
||||
func (fs *Fs) Init() {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Destroy() {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Statfs(path string, stat *fuse.Statfs_t) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Mknod(path string, mode uint32, dev uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Mkdir(path string, mode uint32) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Unlink(path string) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Rmdir(path string) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Link(oldpath string, newpath string) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Symlink(target string, newpath string) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Readlink(path string) (int, string) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Rename(oldpath string, newpath string) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Chmod(path string, mode uint32) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Chown(path string, uid uint32, gid uint32) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Utimens(path string, tmsp []fuse.Timespec) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Access(path string, mask uint32) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Create(path string, flags int, mode uint32) (int, uint64) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Open(path string, flags int) (int, uint64) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Truncate(path string, size int64, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Read(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Write(path string, buff []byte, ofst int64, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Flush(path string, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Release(path string, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Fsync(path string, datasync bool, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Opendir(path string) (int, uint64) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Readdir(path string, fill func(name string, stat *fuse.Stat_t, ofst int64) bool, ofst int64, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Releasedir(path string, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Fsyncdir(path string, datasync bool, fh uint64) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Setxattr(path string, name string, value []byte, flags int) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Getxattr(path string, name string) (int, []byte) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Removexattr(path string, name string) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (fs *Fs) Listxattr(path string, fill func(name string) bool) int {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
var _ fuse.FileSystemInterface = (*Fs)(nil)
|
||||
@@ -1,9 +0,0 @@
|
||||
package fuse
|
||||
|
||||
import "github.com/winfsp/cgofuse/fuse"
|
||||
|
||||
func Mount(mountSrc, mountDst string, opts []string) {
|
||||
fs := &Fs{RootFolder: mountSrc}
|
||||
host := fuse.NewFileSystemHost(fs)
|
||||
go host.Mount(mountDst, opts)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user