mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 21:13:10 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 171aea98cf | |||
| 6ef934f1b5 | |||
| 6ce5aab837 | |||
| 59b1dfde05 | |||
| dd8247e578 | |||
| 7d91598f17 |
@@ -82,17 +82,9 @@ body:
|
||||
label: 复现链接(可选)
|
||||
description: |
|
||||
请提供能复现此问题的链接。
|
||||
- type: checkboxes
|
||||
- type: textarea
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
label: AI生成内容(可选)
|
||||
description: |
|
||||
如果此问题是由AI辅助您发现的,请提供全部聊天记录,包括使用的模型信息。
|
||||
|
||||
@@ -82,17 +82,9 @@ body:
|
||||
label: Reproduction Link (optional)
|
||||
description: |
|
||||
Please provide a link to a repo or page that can reproduce this issue.
|
||||
- type: checkboxes
|
||||
- type: textarea
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI Generated Content
|
||||
description: Select exactly one option. Do not delete or modify the disclosure text.
|
||||
options:
|
||||
- label: I used AI tools to generate this content
|
||||
- label: I did not use AI tools to generate this content
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI model used
|
||||
description: If you used AI tools, enter the model name; otherwise leave this blank.
|
||||
placeholder: xxx
|
||||
label: AI Generated Content (optional)
|
||||
description: |
|
||||
If this issue was identified with the assistance of AI, please provide the complete chat log, including information about the model used.
|
||||
|
||||
@@ -48,17 +48,9 @@ body:
|
||||
label: 附加信息
|
||||
description: |
|
||||
相关的任何其他上下文或截图,或者你觉得有帮助的信息
|
||||
- type: checkboxes
|
||||
- type: textarea
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
label: AI生成内容(可选)
|
||||
description: |
|
||||
如果此请求是由AI辅助您提交的,请提供全部聊天记录,包括使用的模型信息。
|
||||
|
||||
@@ -48,17 +48,9 @@ body:
|
||||
label: Additional Information
|
||||
description: |
|
||||
Any other context or screenshots related to this feature request, or information you find helpful.
|
||||
- type: checkboxes
|
||||
- type: textarea
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI Generated Content
|
||||
description: Select exactly one option. Do not delete or modify the disclosure text.
|
||||
options:
|
||||
- label: I used AI tools to generate this content
|
||||
- label: I did not use AI tools to generate this content
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI model used
|
||||
description: If you used AI tools, enter the model name; otherwise leave this blank.
|
||||
placeholder: xxx
|
||||
label: AI Generated Content (optional)
|
||||
description: |
|
||||
If this request was submitted with the assistance of an AI, please provide the complete chat log, including information about the model used.
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create or update ref
|
||||
id: create-or-update-ref
|
||||
uses: ovsds/create-or-update-ref-action@1157b2991820188d5a8e6f22656775d1d171f762 # v1
|
||||
uses: ovsds/create-or-update-ref-action@v1
|
||||
with:
|
||||
ref: tags/beta
|
||||
sha: ${{ github.sha }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
gh release upload beta "CHANGELOG.md" --clobber
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: beta changelog
|
||||
path: ${{ github.workspace }}/CHANGELOG.md
|
||||
@@ -108,14 +108,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.27.1"
|
||||
go-version: "1.26.4"
|
||||
|
||||
- name: Setup web
|
||||
run: bash build.sh dev web
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Build
|
||||
uses: OpenListTeam/cgo-actions@6fcace5934c36d70503dba06e2396bb58b766130 # v1.2.5
|
||||
uses: OpenListTeam/cgo-actions@v1.2.5
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
flags: ${{ matrix.flags || '-ldflags=' }}
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: beta builds for ${{ env.cleaned_target }}
|
||||
path: ${{ github.workspace }}/build/compress/*
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: benjlevesque/short-sha@dbe07338b37c456ce06d23409b35a56a7815eef4 # v4.0
|
||||
id: short-sha
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.27.1"
|
||||
go-version: "1.26.4"
|
||||
|
||||
- name: Setup web
|
||||
run: bash build.sh dev web
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Build
|
||||
uses: OpenListTeam/cgo-actions@6fcace5934c36d70503dba06e2396bb58b766130 # v1.2.5
|
||||
uses: OpenListTeam/cgo-actions@v1.2.5
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
flags: ${{ contains(matrix.target, '-musl') && '-ldflags=-linkmode external -extldflags ''-static -fpic''' || '-ldflags=' }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openlist_${{ steps.short-sha.outputs.sha }}_${{ matrix.target }}
|
||||
path: build/*
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
name: Issue Auto Reply
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-reply:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check issue for unchecked tasks and reply
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
if (context.payload.issue.title.startsWith('[Announcements]')) return;
|
||||
const titleNotEdited = /(请修改标题|Please modify the title)/i.test(context.payload.issue.title);
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
const aiSection = issueBody.match(/^### (AI生成内容|AI Generated Content)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
|
||||
const aiOptionsPattern = aiSection?.[1] === 'AI生成内容'
|
||||
? /^\s*- \[([ xX])\] 我使用了AI工具生成此内容\r?\n- \[([ xX])\] 我没有使用AI工具生成此内容\s*$/
|
||||
: /^\s*- \[([ xX])\] I used AI tools to generate this content\r?\n- \[([ xX])\] I did not use AI tools to generate this content\s*$/;
|
||||
const aiOptions = (aiSection?.[2] || '').match(aiOptionsPattern);
|
||||
const validAiDisclosure = aiOptions !== null && (aiOptions[1] !== ' ') !== (aiOptions[2] !== ' ');
|
||||
const aiModelSection = issueBody.match(/^### (AI模型是|AI model used)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
|
||||
const aiModel = (aiModelSection?.[2] || '').trim();
|
||||
const missingAiModel = validAiDisclosure && aiOptions[1] !== ' ' && (aiModel === '' || aiModel === '_No response_');
|
||||
const confirmNotRead = /- \[[xX]\] (?:我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
|
||||
const closeIssue = titleNotEdited || confirmNotRead || !validAiDisclosure || missingAiModel;
|
||||
let comment;
|
||||
if (titleNotEdited) {
|
||||
comment = `⚠️ 请修改标题以更好地描述您的问题或需求,并删除示例提示。当前 Issue 将被自动关闭并锁定。如需继续提交,请创建新的 Issue。
|
||||
⚠️ Please modify the title to better describe your issue or request, and remove the example prompt. This issue will be automatically closed and locked. If you wish to proceed, please create a new issue.
|
||||
`;
|
||||
} else if (confirmNotRead || !validAiDisclosure || missingAiModel) {
|
||||
comment = `⚠️ 你的 Issue 不符合提交规则。请先阅读相关规范后再重新提交。当前 Issue 将被自动关闭并锁定。如需继续提交,请确认已了解规则后创建新的 Issue。
|
||||
⚠️ Your issue does not comply with the submission rules. Please read the guidelines before submitting again. This issue will be automatically closed and locked. If you wish to proceed, please confirm that you have reviewed the rules before creating a new issue.
|
||||
`;
|
||||
} else if (/- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody.replace(aiSection[0], ''))) {
|
||||
comment = `感谢您联系OpenList。我们会尽快回复您。
|
||||
Thanks for contacting OpenList. We will reply to you as soon as possible.
|
||||
|
||||
由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。
|
||||
如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!
|
||||
Since your issue contains some unchecked tasks, it may be closed after manual review.
|
||||
If you can confirm and provide information for the unchecked tasks, feel free to resubmit.
|
||||
We will pay attention and handle it in a timely manner.
|
||||
|
||||
感谢您的理解与支持!
|
||||
Thank you for your understanding and support!
|
||||
`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
if (closeIssue) {
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
labels: ['invalid']
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Issue or PR Auto Reply
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request_target:
|
||||
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_target'
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pr-title-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR title for required prefix and comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title;
|
||||
if (/^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title)) return;
|
||||
const comment = `⚠️ PR 标题需以 \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, \`refactor(): \`, \`chore(): \` 其中之一开头,例如:\`feat(component): 新增功能\`。
|
||||
⚠️ The PR title must start with \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, or \`refactor(): \`, \`chore(): \`. For example: \`feat(component): add new feature\`.
|
||||
|
||||
如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。
|
||||
If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.
|
||||
|
||||
如果是破坏性变更,请在类型后添加 \`!\`,例如 \`feat(component)!: 破坏性变更\`。
|
||||
For breaking changes, add \`!\` after the type, e.g., \`feat(component)!: breaking change\`.
|
||||
|
||||
`;
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Prerelease
|
||||
uses: irongut/EditRelease@ccf529ad26dddf9996e7dd0f24ca5da4ea507cc2 # v1.2.0
|
||||
uses: irongut/EditRelease@v1.2.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
id: ${{ github.event.release.id }}
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.target-platform == ''
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
@@ -46,10 +46,10 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.27.1'
|
||||
go-version: '1.26.4'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: true
|
||||
|
||||
@@ -43,15 +43,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.27'
|
||||
go-version: '1.26'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
overwrite: true
|
||||
@@ -83,15 +83,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.27.1'
|
||||
go-version: '1.26.4'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME_LITE }}
|
||||
overwrite: true
|
||||
@@ -146,14 +146,14 @@ jobs:
|
||||
tag_favor: "suffix=-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
@@ -230,14 +230,14 @@ jobs:
|
||||
tag_favor: "suffix=-lite-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME_LITE }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Sync GitHub to Gitee
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.27.1'
|
||||
go-version: '1.26.4'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
overwrite: true
|
||||
@@ -97,14 +97,14 @@ jobs:
|
||||
tag_favor: "suffix=-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Makefile hash update
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
|
||||
script: |
|
||||
|
||||
@@ -97,7 +97,7 @@ AssertStaticBinary() {
|
||||
}
|
||||
|
||||
FetchWebRolling() {
|
||||
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/edge\"")
|
||||
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/rolling\"")
|
||||
pre_release_assets=$(echo "$pre_release_json" | jq -r '.assets[].browser_download_url')
|
||||
|
||||
# There is no lite for rolling
|
||||
|
||||
+7
-17
@@ -8,7 +8,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -32,23 +31,14 @@ func start() {
|
||||
return
|
||||
}
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal("failed to resolve executable path: ", err)
|
||||
args := os.Args
|
||||
args[1] = "server"
|
||||
args = append(args, "--force-bin-dir")
|
||||
cmd := &exec.Cmd{
|
||||
Path: args[0],
|
||||
Args: args,
|
||||
Env: os.Environ(),
|
||||
}
|
||||
childArgs := append([]string{"server"}, os.Args[2:]...)
|
||||
hasForceBinDir := false
|
||||
for _, arg := range childArgs {
|
||||
if arg == "--force-bin-dir" || strings.HasPrefix(arg, "--force-bin-dir=") {
|
||||
hasForceBinDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasForceBinDir {
|
||||
childArgs = append(childArgs, "--force-bin-dir")
|
||||
}
|
||||
cmd := exec.Command(exe, childArgs...)
|
||||
cmd.Env = os.Environ()
|
||||
stdout, err := os.OpenFile(filepath.Join(filepath.Dir(pidFile), "start.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
log.Fatal(os.Getpid(), ": failed to open start log file:", err)
|
||||
|
||||
+4
-4
@@ -7,11 +7,11 @@ import (
|
||||
|
||||
type Addition struct {
|
||||
//Account string `json:"account" required:"true"`
|
||||
Authorization string `json:"authorization" type:"text" help:"Authorization can be used alone. If empty, use username + password; mail_cookies is optional and will be established/updated automatically. Existing mail_cookies can also be used alone for fast login."`
|
||||
Username string `json:"username" help:"Use together with password when Authorization is empty. mail_cookies may be left empty on the first login."`
|
||||
Password string `json:"password" secret:"true" help:"Use together with username when Authorization is empty. mail_cookies may be left empty on the first login."`
|
||||
Authorization string `json:"authorization" type:"text" help:"Authorization can be used alone. If empty, use mail_cookies alone for fast login, or mail_cookies + username + password for full login fallback."`
|
||||
Username string `json:"username" help:"Required only when using password login fallback with mail_cookies."`
|
||||
Password string `json:"password" secret:"true" help:"Required only when using password login fallback with mail_cookies."`
|
||||
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."`
|
||||
MailCookies string `json:"mail_cookies" type:"text" help:"Cookies from mail.10086.cn. Used for fast login only when Authorization is empty; otherwise retained as device context for password login fallback."`
|
||||
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."`
|
||||
|
||||
+142
-30
@@ -46,10 +46,31 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
mailRootURL = "https://mail.10086.cn/"
|
||||
mailPasswordURL = "https://mail.10086.cn/Login/Login.ashx"
|
||||
mailSMSURL = "https://mail.10086.cn/s"
|
||||
)
|
||||
|
||||
var mailLoginCookieExclusions = map[string]struct{}{
|
||||
"hecaiyun_stay_time": {},
|
||||
"isShowAgreeIconNew": {},
|
||||
"hecaiyundata2021jssdkcross": {},
|
||||
"random": {},
|
||||
"a_l2": {},
|
||||
"hecaiyun_stay_url": {},
|
||||
"a_l": {},
|
||||
"_139mail_login_type": {},
|
||||
"_139mail_login_shortAddr": {},
|
||||
"sajssdk_2015_cross_new_user": {},
|
||||
"fromhtml5": {},
|
||||
"html5SkinPath8011": {},
|
||||
"Os_SSo_Sid": {},
|
||||
"sid": {},
|
||||
"Login_UserNumber": {},
|
||||
"RMKEY": {},
|
||||
"rtexpired": {},
|
||||
}
|
||||
|
||||
type credentialState int
|
||||
|
||||
const (
|
||||
@@ -1174,6 +1195,28 @@ func mergeMailCookieHeader(existing string, responseCookies []*http.Cookie) stri
|
||||
return cookiepkg.ToString(cookies)
|
||||
}
|
||||
|
||||
func sanitizeMailLoginCookies(existing, newJSessionID string) string {
|
||||
cookies := cookiepkg.Parse(existing)
|
||||
filtered := cookies[:0]
|
||||
for _, cookie := range cookies {
|
||||
if _, excluded := mailLoginCookieExclusions[cookie.Name]; excluded {
|
||||
continue
|
||||
}
|
||||
if cookie.Name == "JSESSIONID" {
|
||||
if newJSessionID == "" {
|
||||
continue
|
||||
}
|
||||
cookie.Value = newJSessionID
|
||||
newJSessionID = ""
|
||||
}
|
||||
filtered = append(filtered, cookie)
|
||||
}
|
||||
if newJSessionID != "" {
|
||||
filtered = append(filtered, &http.Cookie{Name: "JSESSIONID", Value: newJSessionID})
|
||||
}
|
||||
return cookiepkg.ToString(filtered)
|
||||
}
|
||||
|
||||
func mailRiskCode(location string) string {
|
||||
parsed, err := url.Parse(location)
|
||||
if err != nil {
|
||||
@@ -1228,7 +1271,7 @@ func new139RestyClient() *resty.Client {
|
||||
if base.RestyClient != nil {
|
||||
return base.RestyClient.Clone()
|
||||
}
|
||||
return base.NewRestyClient()
|
||||
return resty.New()
|
||||
}
|
||||
|
||||
func (d *Yun139) sendSMSVerificationCode(riskCode string) error {
|
||||
@@ -1253,7 +1296,7 @@ func (d *Yun139) sendSMSVerificationCode(riskCode string) error {
|
||||
mailXMLField("scene", strconv.Itoa(scene)),
|
||||
"</object>",
|
||||
}, "")
|
||||
res, err := new139RestyClient().SetRetryCount(0).R().
|
||||
res, err := new139RestyClient().R().
|
||||
SetHeaders(mailXMLHeaders(d.MailCookies)).
|
||||
SetBody(body).
|
||||
Post(mailSMSURL + "?func=" + url.QueryEscape("login:sendSmsCodeByScene") + "&cguid=" + strconv.FormatInt(time.Now().UnixMilli(), 10))
|
||||
@@ -1306,7 +1349,7 @@ func (d *Yun139) verifySMSCode(riskCode string) (string, error) {
|
||||
pwdType,
|
||||
"</object>",
|
||||
}, "")
|
||||
res, err := new139RestyClient().SetRetryCount(0).R().
|
||||
res, err := new139RestyClient().R().
|
||||
SetHeaders(mailXMLHeaders(d.MailCookies)).
|
||||
SetBody(body).
|
||||
Post(mailSMSURL + "?func=" + url.QueryEscape("/login/inlogin.action") + "&cguid=" + strconv.FormatInt(time.Now().UnixMilli(), 10))
|
||||
@@ -1343,6 +1386,25 @@ func (d *Yun139) step1_password_login() (string, error) {
|
||||
log.Debugf("--- 执行步骤 1: 登录 API ---")
|
||||
loginURL := mailPasswordURL
|
||||
|
||||
preLogin, err := new139RestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()).R().Get(mailRootURL)
|
||||
if preLogin == nil {
|
||||
return "", fmt.Errorf("step1 pre-login request failed: %v", err)
|
||||
}
|
||||
if err != nil && (preLogin.StatusCode() < 300 || preLogin.StatusCode() >= 400) {
|
||||
return "", fmt.Errorf("step1 pre-login request failed: %w", err)
|
||||
}
|
||||
jsessionid := ""
|
||||
for _, cookie := range preLogin.Cookies() {
|
||||
if cookie.Name == "JSESSIONID" {
|
||||
jsessionid = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if jsessionid == "" {
|
||||
return "", errors.New("step1 pre-login response did not set JSESSIONID")
|
||||
}
|
||||
loginCookies := sanitizeMailLoginCookies(d.MailCookies, jsessionid)
|
||||
|
||||
// 密码 SHA1 哈希
|
||||
hashedPassword := sha1Hash(fmt.Sprintf("fetion.com.cn:%s", d.Password))
|
||||
|
||||
@@ -1366,7 +1428,7 @@ func (d *Yun139) step1_password_login() (string, error) {
|
||||
"sec-fetch-user": "?1",
|
||||
"upgrade-insecure-requests": "1",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0",
|
||||
"Cookie": d.MailCookies,
|
||||
"Cookie": loginCookies,
|
||||
}
|
||||
|
||||
loginData := url.Values{}
|
||||
@@ -1382,25 +1444,31 @@ func (d *Yun139) step1_password_login() (string, error) {
|
||||
log.Debugf("DEBUG: 登录请求 URL: %s", loginURL)
|
||||
log.Debugf("DEBUG: 登录请求已准备")
|
||||
|
||||
res, err := new139RestyClient().
|
||||
SetRetryCount(0).
|
||||
SetRedirectPolicy(resty.RedirectPolicyFunc(func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
})).R().
|
||||
// 设置客户端不跟随重定向
|
||||
client := new139RestyClient().SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
res, err := client.R().
|
||||
SetHeaders(loginHeaders).
|
||||
SetFormDataFromValues(loginData).
|
||||
Post(loginURL)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("step1 login request failed: %w", err)
|
||||
// 如果是重定向错误,则不作为失败处理,因为我们禁止了自动重定向
|
||||
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())
|
||||
}
|
||||
log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode())
|
||||
log.Debugf("DEBUG: 登录响应 Location present: %t", res.Header().Get("Location") != "")
|
||||
|
||||
d.MailCookies = mergeMailCookieHeader(d.MailCookies, res.Cookies())
|
||||
var sid, extractedCguid string
|
||||
|
||||
sid := ""
|
||||
// 从 Location 头部提取 sid 和 cguid
|
||||
locationHeader := res.Header().Get("Location")
|
||||
if locationHeader != "" {
|
||||
d.MailCookies = mergeMailCookieHeader(loginCookies, res.Cookies())
|
||||
if riskCode := mailRiskCode(locationHeader); riskCode != "" {
|
||||
if _, ok := smsSceneForRisk(riskCode); !ok {
|
||||
return "", fmt.Errorf("139 Mail risk control triggered: %s", riskCode)
|
||||
@@ -1414,22 +1482,41 @@ func (d *Yun139) step1_password_login() (string, error) {
|
||||
}
|
||||
return d.verifySMSCode(riskCode)
|
||||
}
|
||||
if redirectURL, parseErr := url.Parse(locationHeader); parseErr == nil {
|
||||
sid = redirectURL.Query().Get("sid")
|
||||
sidMatch := regexp.MustCompile(`sid=([^&]+)`).FindStringSubmatch(locationHeader)
|
||||
cguidMatch := regexp.MustCompile(`cguid=([^&]+)`).FindStringSubmatch(locationHeader)
|
||||
if len(sidMatch) > 1 {
|
||||
sid = sidMatch[1]
|
||||
log.Debugf("DEBUG: 从 Location 提取到 sid.")
|
||||
}
|
||||
if len(cguidMatch) > 1 {
|
||||
extractedCguid = cguidMatch[1]
|
||||
log.Debugf("DEBUG: 从 Location 提取到 cguid.")
|
||||
}
|
||||
}
|
||||
|
||||
if sid == "" {
|
||||
for _, cookie := range res.Cookies() {
|
||||
if cookie.Name == "Os_SSo_Sid" || cookie.Name == "sid" {
|
||||
sid = cookie.Value
|
||||
break
|
||||
// 如果 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.")
|
||||
}
|
||||
if len(cookieCguidMatch) > 1 && extractedCguid == "" {
|
||||
extractedCguid = cookieCguidMatch[1]
|
||||
log.Debugf("DEBUG: 从 Set-Cookie 提取到 cguid.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if sid == "" {
|
||||
return "", errors.New("failed to extract sid from login response")
|
||||
|
||||
if sid == "" || extractedCguid == "" {
|
||||
return "", errors.New("failed to extract sid or cguid from login response")
|
||||
}
|
||||
|
||||
d.MailCookies = mergeMailCookieHeader(loginCookies, res.Cookies())
|
||||
|
||||
return sid, nil
|
||||
}
|
||||
|
||||
@@ -1804,6 +1891,10 @@ func extractFastLoginCookies(mailCookies string) (sid string, rmkey string) {
|
||||
return sid, rmkey
|
||||
}
|
||||
|
||||
func isRedirectStatus(statusCode int) bool {
|
||||
return statusCode >= 300 && statusCode <= 399
|
||||
}
|
||||
|
||||
func hasCookiePair(raw string) bool {
|
||||
for _, part := range strings.Split(raw, ";") {
|
||||
name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
@@ -1814,6 +1905,26 @@ func hasCookiePair(raw string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func fetchMailJSessionID(endpoint string) (string, error) {
|
||||
client := new139RestyClient().SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
res, err := client.R().Get(endpoint)
|
||||
if res == nil {
|
||||
return "", fmt.Errorf("pre-login request returned no response: %v", err)
|
||||
}
|
||||
if err != nil && !isRedirectStatus(res.StatusCode()) {
|
||||
return "", fmt.Errorf("pre-login request failed with status %d: %w", res.StatusCode(), err)
|
||||
}
|
||||
if res.StatusCode() >= http.StatusBadRequest {
|
||||
return "", fmt.Errorf("pre-login request failed with status %d", res.StatusCode())
|
||||
}
|
||||
for _, cookie := range res.Cookies() {
|
||||
if cookie.Name == "JSESSIONID" && cookie.Value != "" {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("pre-login response did not set JSESSIONID")
|
||||
}
|
||||
|
||||
func (d *Yun139) tryFastLoginWithCookies() bool {
|
||||
sid, rmkey := extractFastLoginCookies(d.MailCookies)
|
||||
if sid == "" || rmkey == "" {
|
||||
@@ -1854,7 +1965,7 @@ func (d *Yun139) validateAndInitCredentials() error {
|
||||
return nil
|
||||
case credentialStateFullLogin, credentialStateCookiesOnly:
|
||||
log.Infof("139yun: Authorization missing, attempting login...")
|
||||
if d.MailCookies != "" && d.tryFastLoginWithCookies() {
|
||||
if d.tryFastLoginWithCookies() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1891,15 +2002,16 @@ func (d *Yun139) credentialState() (credentialState, error) {
|
||||
|
||||
hasUsername := d.Username != ""
|
||||
hasPassword := strings.TrimSpace(d.Password) != ""
|
||||
hasCookies := d.MailCookies != ""
|
||||
|
||||
if hasUsername != hasPassword {
|
||||
return 0, fmt.Errorf("username and password must be provided together")
|
||||
}
|
||||
if hasUsername {
|
||||
if hasUsername || hasPassword {
|
||||
if !hasUsername || !hasPassword || !hasCookies {
|
||||
return 0, fmt.Errorf("if username or password is provided, all three (mail_cookies, username, password) must be provided")
|
||||
}
|
||||
return credentialStateFullLogin, nil
|
||||
}
|
||||
|
||||
if d.MailCookies != "" {
|
||||
if hasCookies {
|
||||
return credentialStateCookiesOnly, nil
|
||||
}
|
||||
|
||||
@@ -1907,8 +2019,8 @@ func (d *Yun139) credentialState() (credentialState, error) {
|
||||
}
|
||||
|
||||
func (d *Yun139) loginWithPassword() (string, error) {
|
||||
if d.Username == "" || d.Password == "" {
|
||||
return "", errors.New("username or password is empty")
|
||||
if d.Username == "" || d.Password == "" || d.MailCookies == "" {
|
||||
return "", errors.New("username, password or mail_cookies is empty")
|
||||
}
|
||||
|
||||
passId, err := d.step1_password_login()
|
||||
|
||||
+58
-124
@@ -1,7 +1,6 @@
|
||||
package _139
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,13 +13,12 @@ import (
|
||||
"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 TestSanitizeLoginCookiesDropsStaleJSessionIDWhenFreshOneMissing(t *testing.T) {
|
||||
got := sanitizeMailLoginCookies("JSESSIONID=old; behaviorid=b", "")
|
||||
want := "behaviorid=b"
|
||||
if got != want {
|
||||
t.Fatalf("sanitizeMailLoginCookies() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMailCookiesPreservesExistingOrderAndAppendsNewNames(t *testing.T) {
|
||||
@@ -63,14 +61,6 @@ func TestCredentialState(t *testing.T) {
|
||||
}},
|
||||
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"}},
|
||||
@@ -152,31 +142,32 @@ func TestIntegrationLoginObtainsAuthorization(t *testing.T) {
|
||||
t.Fatal("OPENLIST_139_MAIL_COOKIES is required")
|
||||
}
|
||||
|
||||
runFastLogin := func(mailCookies string) error {
|
||||
runFastLogin := func(t *testing.T, mailCookies string) {
|
||||
t.Helper()
|
||||
d := Yun139{Addition: Addition{MailCookies: mailCookies}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("credentialState() error: %w", err)
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if state != credentialStateCookiesOnly {
|
||||
return fmt.Errorf("credentialState() = %v, want cookies only", state)
|
||||
t.Fatalf("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")
|
||||
t.Fatal("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)
|
||||
t.Fatalf("step2_get_single_token() error: %v", err)
|
||||
}
|
||||
auth, err := d.step3_third_party_login(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step3_third_party_login() error: %w", err)
|
||||
t.Fatalf("step3_third_party_login() error: %v", err)
|
||||
}
|
||||
if auth == "" {
|
||||
return fmt.Errorf("authorization is empty after fast login")
|
||||
d.Authorization = auth
|
||||
if d.Authorization == "" {
|
||||
t.Fatal("authorization is empty after fast login")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
@@ -240,21 +231,46 @@ func TestIntegrationLoginObtainsAuthorization(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
runFastLogin(t, mailCookies)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
runFastLogin(t, refreshedMailCookies)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsRedirectStatus(t *testing.T) {
|
||||
for _, status := range []int{300, 301, 302, 307, 399} {
|
||||
if !isRedirectStatus(status) {
|
||||
t.Fatalf("isRedirectStatus(%d) = false, want true", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []int{200, 299, 400, 500} {
|
||||
if isRedirectStatus(status) {
|
||||
t.Fatalf("isRedirectStatus(%d) = true, want false", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMailJSessionIDAcceptsRedirectResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "JSESSIONID", Value: "fresh"})
|
||||
http.Redirect(w, r, "/next", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
got, err := fetchMailJSessionID(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchMailJSessionID() error: %v", err)
|
||||
}
|
||||
if got != "fresh" {
|
||||
t.Fatalf("fetchMailJSessionID() = %q, want fresh", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidAuthorizationDoesNotUseCookieFastLogin(t *testing.T) {
|
||||
d := Yun139{Addition: Addition{
|
||||
Authorization: "not-base64",
|
||||
@@ -288,9 +304,18 @@ func TestSMSSceneForRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSMSVerificationCode(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
func TestSanitizeMailLoginCookiesKeepsDeviceContext(t *testing.T) {
|
||||
got := sanitizeMailLoginCookies(
|
||||
"behaviorid=device; Os_SSo_Sid=old-sid; RMKEY=old-rmkey; JSESSIONID=old-session; S_DEVICE_TOKEN=fingerprint",
|
||||
"new-session",
|
||||
)
|
||||
want := "behaviorid=device;JSESSIONID=new-session;S_DEVICE_TOKEN=fingerprint"
|
||||
if got != want {
|
||||
t.Fatalf("sanitizeMailLoginCookies() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSMSVerificationCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -324,8 +349,6 @@ func TestSendSMSVerificationCode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendSMSVerificationCodeStopsAtPictureChallenge(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"code":"PML401010021"}`)
|
||||
}))
|
||||
@@ -343,8 +366,6 @@ func TestSendSMSVerificationCodeStopsAtPictureChallenge(t *testing.T) {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -385,90 +406,3 @@ func TestVerifySMSCode(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -60,13 +59,7 @@ func timestamp() int64 {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Unmarshal([]byte(s))
|
||||
}
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package _189_tv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -19,13 +17,11 @@ func TestTimeUnmarshal(t *testing.T) {
|
||||
{"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 {
|
||||
if err := tm.Unmarshal([]byte(tt.input)); err != nil {
|
||||
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
|
||||
}
|
||||
if !tt.want.Equal(time.Time(tm)) {
|
||||
@@ -41,25 +37,3 @@ func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -103,13 +102,7 @@ func MustParseTime(str string) *time.Time {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Unmarshal([]byte(s))
|
||||
}
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package _189pc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -19,13 +17,11 @@ func TestTimeUnmarshal(t *testing.T) {
|
||||
{"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 {
|
||||
if err := tm.Unmarshal([]byte(tt.input)); err != nil {
|
||||
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
|
||||
}
|
||||
if !tt.want.Equal(time.Time(tm)) {
|
||||
@@ -41,25 +37,3 @@ func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,13 +394,7 @@ func (d *GuangYaPan) Put(ctx context.Context, dstDir model.Obj, file model.FileS
|
||||
|
||||
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)
|
||||
token, code, err := d.getUploadToken(ctx, parentID, name, file.GetSize())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -133,39 +133,15 @@ func (d *GuangYaPan) waitTaskDone(ctx context.Context, taskID string) error {
|
||||
|
||||
// --- 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) {
|
||||
func (d *GuangYaPan) getUploadToken(ctx context.Context, parentID, name string, size int64) (*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,
|
||||
"res": map[string]any{
|
||||
"fileSize": size,
|
||||
},
|
||||
}, &out)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -265,7 +241,6 @@ func (d *GuangYaPan) multipartUploadToOSS(ctx context.Context, bucket *oss.Bucke
|
||||
}
|
||||
|
||||
parts := make([]oss.UploadPart, 0, partCount)
|
||||
var uploaded int64
|
||||
for i := 0; i < partCount; i++ {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
@@ -298,10 +273,6 @@ func (d *GuangYaPan) multipartUploadToOSS(ctx context.Context, bucket *oss.Bucke
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
@@ -152,7 +153,8 @@ func (d *Local) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([
|
||||
func (d *Local) FileInfoToObj(ctx context.Context, f fs.FileInfo, reqPath string, fullPath string) model.Obj {
|
||||
thumb := ""
|
||||
if d.Thumbnail {
|
||||
if d.supportsThumbnail(f.Name()) {
|
||||
typeName := utils.GetFileType(f.Name())
|
||||
if typeName == conf.IMAGE || typeName == conf.VIDEO {
|
||||
thumb = common.GetApiUrl(ctx) + stdpath.Join("/d", reqPath, f.Name())
|
||||
thumb = utils.EncodePath(thumb, true)
|
||||
thumb += "?type=thumb&sign=" + sign.Sign(stdpath.Join(reqPath, f.Name()))
|
||||
@@ -238,7 +240,7 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
var thumbPath *string
|
||||
err := d.thumbTokenBucket.Do(ctx, func() error {
|
||||
var err error
|
||||
buf, thumbPath, err = d.getThumb(ctx, file)
|
||||
buf, thumbPath, err = d.getThumb(file)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ type Addition struct {
|
||||
driver.RootPath
|
||||
DirectorySize bool `json:"directory_size" default:"false" help:"This might impact host performance"`
|
||||
Thumbnail bool `json:"thumbnail" required:"true" help:"enable thumbnail"`
|
||||
PDFThumbnail bool `json:"pdf_thumbnail" default:"false" required:"false" help:"Generate PDF first-page thumbnails with Quick Look on macOS"`
|
||||
ThumbCacheFolder string `json:"thumb_cache_folder"`
|
||||
ThumbConcurrency string `json:"thumb_concurrency" default:"16" required:"false" help:"Number of concurrent thumbnail generation goroutines. This controls how many thumbnails can be generated in parallel."`
|
||||
VideoThumbPos string `json:"video_thumb_pos" default:"20%" required:"false" help:"The position of the video thumbnail. If the value is a number (integer ot floating point), it represents the time in seconds. If the value ends with '%', it represents the percentage of the video duration."`
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pdfThumbnailSupported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func renderPDFThumbnail(ctx context.Context, fullPath string) (*bytes.Buffer, error) {
|
||||
tempDir, err := os.MkdirTemp("", "openlist-pdf-thumb-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
renderCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(renderCtx, "/usr/bin/qlmanage", "-t", "-s", "512", "-o", tempDir, fullPath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
if renderCtx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("render PDF thumbnail timed out: %w", renderCtx.Err())
|
||||
}
|
||||
if renderCtx.Err() != nil {
|
||||
return nil, fmt.Errorf("render PDF thumbnail canceled: %w", renderCtx.Err())
|
||||
}
|
||||
return nil, fmt.Errorf("render PDF thumbnail: %w: %s", err, bytes.TrimSpace(output))
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(tempDir, filepath.Base(fullPath)+".png")
|
||||
data, err := os.ReadFile(thumbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read rendered PDF thumbnail: %w", err)
|
||||
}
|
||||
return bytes.NewBuffer(data), nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderPDFThumbnailDarwin(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
textPath := filepath.Join(tempDir, "source.txt")
|
||||
pdfPath := filepath.Join(tempDir, "source 文件.pdf")
|
||||
if err := os.WriteFile(textPath, []byte("OpenList PDF thumbnail integration test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/sbin/cupsfilter", textPath)
|
||||
pdfData, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("create fixture PDF: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(pdfPath, pdfData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumb, err := renderPDFThumbnail(context.Background(), pdfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.HasPrefix(thumb.Bytes(), []byte("\x89PNG\r\n\x1a\n")) {
|
||||
t.Fatal("rendered thumbnail is not PNG")
|
||||
}
|
||||
cfg, err := png.DecodeConfig(bytes.NewReader(thumb.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode thumbnail: %v", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
t.Fatalf("invalid thumbnail dimensions: %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := renderPDFThumbnail(canceledCtx, pdfPath); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("renderPDFThumbnail with canceled context returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
)
|
||||
|
||||
func TestSupportsThumbnail(t *testing.T) {
|
||||
oldImages := conf.SlicesMap[conf.ImageTypes]
|
||||
oldVideos := conf.SlicesMap[conf.VideoTypes]
|
||||
conf.SlicesMap[conf.ImageTypes] = []string{"jpg"}
|
||||
conf.SlicesMap[conf.VideoTypes] = []string{"mp4"}
|
||||
t.Cleanup(func() {
|
||||
conf.SlicesMap[conf.ImageTypes] = oldImages
|
||||
conf.SlicesMap[conf.VideoTypes] = oldVideos
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
pdfThumbnail bool
|
||||
want bool
|
||||
}{
|
||||
{name: "image", fileName: "cover.jpg", want: true},
|
||||
{name: "video", fileName: "movie.mp4", want: true},
|
||||
{name: "PDF disabled by default", fileName: "document.pdf", want: false},
|
||||
{name: "unrelated document", fileName: "document.txt", pdfThumbnail: true, want: false},
|
||||
{name: "PDF enabled when renderer is available", fileName: "document.PDF", pdfThumbnail: true, want: pdfThumbnailSupported()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d := &Local{Addition: Addition{PDFThumbnail: tt.pdfThumbnail}}
|
||||
if got := d.supportsThumbnail(tt.fileName); got != tt.want {
|
||||
t.Fatalf("supportsThumbnail(%q) = %v, want %v", tt.fileName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func pdfThumbnailSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func renderPDFThumbnail(context.Context, string) (*bytes.Buffer, error) {
|
||||
return nil, errors.New("PDF thumbnails are not supported on this platform")
|
||||
}
|
||||
+2
-22
@@ -2,7 +2,6 @@ package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -128,19 +127,7 @@ func (d *Local) removeThumbCache(fullPath string) {
|
||||
_ = os.Remove(thumbPath)
|
||||
}
|
||||
|
||||
func (d *Local) supportsThumbnail(name string) bool {
|
||||
typeName := utils.GetFileType(name)
|
||||
if typeName == conf.IMAGE || typeName == conf.VIDEO {
|
||||
return true
|
||||
}
|
||||
return d.supportsPDFThumbnail(name)
|
||||
}
|
||||
|
||||
func (d *Local) supportsPDFThumbnail(name string) bool {
|
||||
return d.PDFThumbnail && pdfThumbnailSupported() && strings.EqualFold(filepath.Ext(name), ".pdf")
|
||||
}
|
||||
|
||||
func (d *Local) getThumb(ctx context.Context, file model.Obj) (*bytes.Buffer, *string, error) {
|
||||
func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
|
||||
fullPath := file.GetPath()
|
||||
if d.ThumbCacheFolder != "" {
|
||||
// skip if the file is a thumbnail
|
||||
@@ -153,19 +140,12 @@ func (d *Local) getThumb(ctx context.Context, file model.Obj) (*bytes.Buffer, *s
|
||||
}
|
||||
}
|
||||
var srcBuf *bytes.Buffer
|
||||
typeName := utils.GetFileType(file.GetName())
|
||||
if typeName == conf.VIDEO {
|
||||
if utils.GetFileType(file.GetName()) == conf.VIDEO {
|
||||
videoBuf, err := d.GetSnapshot(fullPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
srcBuf = videoBuf
|
||||
} else if d.supportsPDFThumbnail(file.GetName()) {
|
||||
pdfBuf, err := renderPDFThumbnail(ctx, fullPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
srcBuf = pdfBuf
|
||||
} else {
|
||||
imgData, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
module github.com/OpenListTeam/OpenList/v4
|
||||
|
||||
go 1.26.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.27.1
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
|
||||
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.20260911144636-404e34e0f5af
|
||||
github.com/OpenListTeam/gofakes3 v0.8.1
|
||||
github.com/OpenListTeam/sftpd-openlist v1.0.1
|
||||
github.com/OpenListTeam/tache v0.2.2
|
||||
github.com/OpenListTeam/times v0.1.0
|
||||
@@ -24,14 +24,14 @@ require (
|
||||
github.com/avast/retry-go v3.0.0+incompatible
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/blevesearch/bleve/v2 v2.6.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||
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-20260803221621-0b399b9d036c
|
||||
github.com/coreos/go-oidc v2.5.0+incompatible
|
||||
github.com/coreos/go-oidc v2.3.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
|
||||
@@ -44,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.18.0
|
||||
github.com/go-webauthn/webauthn v0.13.4
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -64,8 +64,8 @@ require (
|
||||
github.com/pkg/sftp v1.13.11
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/quic-go/quic-go v0.61.0
|
||||
github.com/rclone/rclone v1.75.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.8
|
||||
github.com/rclone/rclone v1.75.0
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/sirupsen/logrus v1.10.2
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -75,9 +75,9 @@ require (
|
||||
github.com/u2takey/ffmpeg-go v0.5.0
|
||||
github.com/upyun/go-sdk/v3 v3.0.4
|
||||
github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3
|
||||
golang.org/x/crypto v0.56.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/appengine v1.6.8
|
||||
@@ -117,7 +117,6 @@ require (
|
||||
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
|
||||
@@ -132,12 +131,10 @@ require (
|
||||
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
|
||||
@@ -222,7 +219,7 @@ 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.3 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // 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.3.1 // indirect
|
||||
@@ -231,12 +228,12 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // 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/go-webauthn/x v0.1.23 // 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 v1.0.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // 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
|
||||
@@ -312,10 +309,10 @@ require (
|
||||
golang.org/x/sync v0.22.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/text v0.40.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/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6
|
||||
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
|
||||
|
||||
@@ -13,8 +13,6 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEB
|
||||
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=
|
||||
@@ -51,8 +49,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.20260911144636-404e34e0f5af h1:pcORNNaOgbc28g9YliwPc5mpEmGf/dKyZjkXhHZgvVo=
|
||||
github.com/OpenListTeam/gofakes3 v0.8.2-0.20260911144636-404e34e0f5af/go.mod h1:mS9Ywbo6aId6BrRzeYjOIOpK0QDVnMoKOIb0hpaQZ3U=
|
||||
github.com/OpenListTeam/gofakes3 v0.8.1 h1:uihJ7Zgb4qIafFcXhcm71BzxCyGRIqBVJYg4YOUa6uY=
|
||||
github.com/OpenListTeam/gofakes3 v0.8.1/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=
|
||||
@@ -244,8 +242,6 @@ github.com/blevesearch/zapx/v17 v17.2.3 h1:UYYJPAt5b2tVxldx5h0jmv23RMsg8/UZKFVya
|
||||
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=
|
||||
@@ -314,8 +310,6 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
|
||||
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=
|
||||
@@ -373,8 +367,6 @@ 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=
|
||||
@@ -427,16 +419,10 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
|
||||
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=
|
||||
@@ -468,8 +454,6 @@ 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=
|
||||
@@ -715,8 +699,6 @@ github.com/nwaples/rardecode/v2 v2.4.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsR
|
||||
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=
|
||||
@@ -771,8 +753,6 @@ 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=
|
||||
@@ -799,8 +779,6 @@ github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5
|
||||
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=
|
||||
@@ -851,8 +829,6 @@ 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=
|
||||
@@ -946,10 +922,6 @@ 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=
|
||||
@@ -963,8 +935,6 @@ 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=
|
||||
@@ -988,8 +958,6 @@ 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=
|
||||
@@ -1054,8 +1022,6 @@ 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=
|
||||
@@ -1089,8 +1055,6 @@ 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=
|
||||
|
||||
@@ -15,28 +15,18 @@ import (
|
||||
)
|
||||
|
||||
func initUser() {
|
||||
initAdmin()
|
||||
initGuest()
|
||||
}
|
||||
|
||||
func initAdmin() {
|
||||
_, err := op.GetAdmin()
|
||||
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
|
||||
}
|
||||
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),
|
||||
@@ -57,10 +47,7 @@ func initAdmin() {
|
||||
utils.Log.Fatalf("[init user] Failed to get admin user: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initGuest() {
|
||||
_, err := op.GetGuest()
|
||||
_, err = op.GetGuest()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
salt := random.String(16)
|
||||
|
||||
@@ -13,14 +13,6 @@ 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 {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+11
-70
@@ -2,8 +2,6 @@ package op
|
||||
|
||||
import (
|
||||
stdpath "path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/db"
|
||||
@@ -17,9 +15,6 @@ import (
|
||||
)
|
||||
|
||||
var metaCache = cache.NewMemCache(cache.WithShards[*model.Meta](2))
|
||||
var metaSnapshotMu sync.Mutex
|
||||
var metaSnapshot []model.Meta
|
||||
var metaSnapshotLoaded bool
|
||||
|
||||
// metaG maybe not needed
|
||||
var metaG singleflight.Group[*model.Meta]
|
||||
@@ -28,56 +23,17 @@ func GetNearestMeta(path string) (*model.Meta, error) {
|
||||
return getNearestMeta(utils.FixAndCleanPath(path))
|
||||
}
|
||||
func getNearestMeta(path string) (*model.Meta, error) {
|
||||
var metas []model.Meta
|
||||
loaded := false
|
||||
for {
|
||||
meta, err := GetMetaByPath(path)
|
||||
if err == nil {
|
||||
return meta, nil
|
||||
}
|
||||
if errors.Cause(err) != errs.MetaNotFound {
|
||||
return nil, err
|
||||
}
|
||||
if !loaded {
|
||||
metas, err = getMetaSnapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
var matched *model.Meta
|
||||
for i := range metas {
|
||||
if !strings.EqualFold(utils.FixAndCleanPath(metas[i].Path), path) {
|
||||
continue
|
||||
}
|
||||
if matched != nil {
|
||||
return nil, errors.Errorf("multiple metas match %q case-insensitively", path)
|
||||
}
|
||||
matched = &metas[i]
|
||||
}
|
||||
if matched != nil {
|
||||
return matched, nil
|
||||
}
|
||||
if path == "/" {
|
||||
return nil, errs.MetaNotFound
|
||||
}
|
||||
path = stdpath.Dir(path)
|
||||
meta, err := GetMetaByPath(path)
|
||||
if err == nil {
|
||||
return meta, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getMetaSnapshot() ([]model.Meta, error) {
|
||||
metaSnapshotMu.Lock()
|
||||
defer metaSnapshotMu.Unlock()
|
||||
if metaSnapshotLoaded {
|
||||
return metaSnapshot, nil
|
||||
}
|
||||
metas, err := db.GetAllMetas()
|
||||
if err != nil {
|
||||
if errors.Cause(err) != errs.MetaNotFound {
|
||||
return nil, err
|
||||
}
|
||||
metaSnapshot = metas
|
||||
metaSnapshotLoaded = true
|
||||
return metaSnapshot, nil
|
||||
if path == "/" {
|
||||
return nil, errs.MetaNotFound
|
||||
}
|
||||
return getNearestMeta(stdpath.Dir(path))
|
||||
}
|
||||
|
||||
func GetMetaByPath(path string) (*model.Meta, error) {
|
||||
@@ -112,12 +68,7 @@ func DeleteMetaById(id uint) error {
|
||||
return err
|
||||
}
|
||||
metaCache.Del(old.Path)
|
||||
metaSnapshotMu.Lock()
|
||||
defer metaSnapshotMu.Unlock()
|
||||
err = db.DeleteMetaById(id)
|
||||
metaSnapshot = nil
|
||||
metaSnapshotLoaded = false
|
||||
return err
|
||||
return db.DeleteMetaById(id)
|
||||
}
|
||||
|
||||
func UpdateMeta(u *model.Meta) error {
|
||||
@@ -128,23 +79,13 @@ func UpdateMeta(u *model.Meta) error {
|
||||
}
|
||||
metaCache.Del(old.Path)
|
||||
metaCache.Del(u.Path)
|
||||
metaSnapshotMu.Lock()
|
||||
defer metaSnapshotMu.Unlock()
|
||||
err = db.UpdateMeta(u)
|
||||
metaSnapshot = nil
|
||||
metaSnapshotLoaded = false
|
||||
return err
|
||||
return db.UpdateMeta(u)
|
||||
}
|
||||
|
||||
func CreateMeta(u *model.Meta) error {
|
||||
u.Path = utils.FixAndCleanPath(u.Path)
|
||||
metaCache.Del(u.Path)
|
||||
metaSnapshotMu.Lock()
|
||||
defer metaSnapshotMu.Unlock()
|
||||
err := db.CreateMeta(u)
|
||||
metaSnapshot = nil
|
||||
metaSnapshotLoaded = false
|
||||
return err
|
||||
return db.CreateMeta(u)
|
||||
}
|
||||
|
||||
func GetMetaById(id uint) (*model.Meta, error) {
|
||||
|
||||
@@ -30,20 +30,6 @@ func GetStorageAndActualPath(rawPath string) (storage driver.Driver, actualPath
|
||||
return
|
||||
}
|
||||
|
||||
// GetStorageVirtualMountPath returns the deterministic virtual mount path
|
||||
// without advancing the balanced-storage counter.
|
||||
func GetStorageVirtualMountPath(rawPath string) (string, error) {
|
||||
rawPath = utils.FixAndCleanPath(rawPath)
|
||||
storages := getStoragesByPath(rawPath)
|
||||
if len(storages) == 0 {
|
||||
if rawPath == "/" {
|
||||
return "", errs.NewErr(errs.StorageNotFound, "please add a storage first")
|
||||
}
|
||||
return "", errs.NewErr(errs.StorageNotFound, "rawPath: %s", rawPath)
|
||||
}
|
||||
return utils.FixAndCleanPath(utils.GetActualMountPath(storages[0].GetStorage().MountPath)), nil
|
||||
}
|
||||
|
||||
// urlTreeSplitLineFormPath 分割path中分割真实路径和UrlTree定义字符串
|
||||
func urlTreeSplitLineFormPath(path string) (pp string, file string) {
|
||||
// url.PathUnescape 会移除 // ,手动加回去
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
type TaskExtension struct {
|
||||
tache.Base
|
||||
Creator *model.User
|
||||
StartTime *time.Time `json:"start_time,omitempty"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
startTime *time.Time
|
||||
endTime *time.Time
|
||||
TotalBytes int64
|
||||
ApiUrl string
|
||||
}
|
||||
@@ -38,23 +38,23 @@ func (t *TaskExtension) GetCreator() *model.User {
|
||||
}
|
||||
|
||||
func (t *TaskExtension) SetStartTime(startTime time.Time) {
|
||||
t.StartTime = &startTime
|
||||
t.startTime = &startTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) GetStartTime() *time.Time {
|
||||
return t.StartTime
|
||||
return t.startTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) SetEndTime(endTime time.Time) {
|
||||
t.EndTime = &endTime
|
||||
t.endTime = &endTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) GetEndTime() *time.Time {
|
||||
return t.EndTime
|
||||
return t.endTime
|
||||
}
|
||||
|
||||
func (t *TaskExtension) ClearEndTime() {
|
||||
t.EndTime = nil
|
||||
t.endTime = nil
|
||||
}
|
||||
|
||||
func (t *TaskExtension) SetTotalBytes(totalBytes int64) {
|
||||
|
||||
@@ -2,12 +2,5 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:best-practices"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"matchPackageNames": ["alpine", "ghcr.io/openlistteam/openlist-base-image", "openlistteam/openlist:latest"],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-17
@@ -44,10 +44,7 @@ func CanWriteContentBypassUserPerms(meta *model.Meta, path string) bool {
|
||||
if meta == nil || !meta.Write {
|
||||
return false
|
||||
}
|
||||
if utils.PathEqual(meta.Path, path) {
|
||||
return true
|
||||
}
|
||||
return utils.IsSubPath(meta.Path, path) && meta.WSub
|
||||
return MetaCoversPath(meta.Path, path, meta.WSub)
|
||||
}
|
||||
|
||||
func CanAccess(user *model.User, meta *model.Meta, reqPath string, password string) bool {
|
||||
@@ -81,21 +78,10 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri
|
||||
}
|
||||
|
||||
func MetaCoversPath(metaPath, reqPath string, applyToSubFolder bool) bool {
|
||||
metaPath = utils.FixAndCleanPath(metaPath)
|
||||
reqPath = utils.FixAndCleanPath(reqPath)
|
||||
if strings.EqualFold(metaPath, reqPath) {
|
||||
if utils.PathEqual(metaPath, reqPath) {
|
||||
return true
|
||||
}
|
||||
if !applyToSubFolder {
|
||||
return false
|
||||
}
|
||||
for reqPath != "/" {
|
||||
reqPath = path.Dir(reqPath)
|
||||
if strings.EqualFold(metaPath, reqPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return utils.IsSubPath(metaPath, reqPath) && applyToSubFolder
|
||||
}
|
||||
|
||||
// ShouldProxy TODO need optimize
|
||||
|
||||
@@ -84,27 +84,6 @@ func TestCoversPath(t *testing.T) {
|
||||
applySub: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive exact path match",
|
||||
metaPath: "/Folder",
|
||||
reqPath: "/folder",
|
||||
applySub: false,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive sub path match",
|
||||
metaPath: "/Folder",
|
||||
reqPath: "/folder/Subfolder",
|
||||
applySub: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive sibling prefix does not match",
|
||||
metaPath: "/Folder",
|
||||
reqPath: "/folder-name",
|
||||
applySub: true,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -187,17 +166,6 @@ func TestCanWriteContentIgnoringUserPerms(t *testing.T) {
|
||||
want: false,
|
||||
reason: "non-sub path should deny write even with WSub=true",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive match does not broaden write bypass",
|
||||
meta: &model.Meta{
|
||||
Path: "/Folder",
|
||||
Write: true,
|
||||
WSub: true,
|
||||
},
|
||||
path: "/folder/subfolder",
|
||||
want: false,
|
||||
reason: "case-insensitive matching should only enforce restrictions, not grant write bypass",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -611,23 +579,6 @@ func TestCanAccessWithReadPermissions(t *testing.T) {
|
||||
want: false,
|
||||
reason: "user not in ReadUsers list should be denied",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive exact path still requires password",
|
||||
user: &model.User{
|
||||
ID: 1,
|
||||
Role: model.GENERAL,
|
||||
Permission: 0,
|
||||
},
|
||||
meta: &model.Meta{
|
||||
Path: "/Folder",
|
||||
Password: "secret",
|
||||
PSub: false,
|
||||
},
|
||||
reqPath: "/folder",
|
||||
password: "wrong",
|
||||
want: false,
|
||||
reason: "changing only path casing must not bypass an exact-path password",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+1
-22
@@ -3,17 +3,14 @@ package handles
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"image/png"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/db"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LoginReq struct {
|
||||
@@ -114,29 +111,11 @@ func UpdateCurrent(c *gin.Context) {
|
||||
common.ErrorStrResp(c, model.GuestCannotUpdateProfile, 403)
|
||||
return
|
||||
}
|
||||
ssoID := req.SsoID
|
||||
if req.SsoID != "" && req.SsoID != user.SsoID {
|
||||
claims, err := parseSSOBindingToken(c, req.SsoID, ssoBindingProofPurpose)
|
||||
if err != nil {
|
||||
common.ErrorStrResp(c, "invalid or expired SSO binding proof", 400)
|
||||
return
|
||||
}
|
||||
boundUser, err := db.GetUserBySSOID(claims.SsoID)
|
||||
if err == nil && boundUser.ID != user.ID {
|
||||
common.ErrorStrResp(c, "SSO account is already bound to another user", 409)
|
||||
return
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ErrorResp(c, err, 500)
|
||||
return
|
||||
}
|
||||
ssoID = claims.SsoID
|
||||
}
|
||||
user.Username = req.Username
|
||||
if req.Password != "" {
|
||||
user.SetPassword(req.Password)
|
||||
}
|
||||
user.SsoID = ssoID
|
||||
user.SsoID = req.SsoID
|
||||
if err := op.UpdateUser(user); err != nil {
|
||||
common.ErrorResp(c, err, 500)
|
||||
} else {
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/fs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type FsGetDirectUploadInfoReq struct {
|
||||
@@ -46,40 +44,8 @@ func FsGetDirectUploadInfo(c *gin.Context) {
|
||||
common.ErrorResp(c, err, 403)
|
||||
return
|
||||
}
|
||||
// Resolve the destination once so permission checks and the issued upload
|
||||
// capability cannot target different paths.
|
||||
dstPath := stdpath.Join(path, req.FileName)
|
||||
path = stdpath.Dir(dstPath)
|
||||
req.FileName = stdpath.Base(dstPath)
|
||||
parentMeta, err := op.GetNearestMeta(path)
|
||||
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
|
||||
common.ErrorResp(c, err, 500, true)
|
||||
return
|
||||
}
|
||||
if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, path) {
|
||||
common.ErrorResp(c, errs.PermissionDenied, 403)
|
||||
return
|
||||
}
|
||||
if !common.CanWrite(user, parentMeta, path) {
|
||||
common.ErrorResp(c, errs.PermissionDenied, 403)
|
||||
return
|
||||
}
|
||||
// A single-segment file name can still name a nested mount point.
|
||||
parentMountPath, err := op.GetStorageVirtualMountPath(path)
|
||||
if err != nil {
|
||||
common.ErrorResp(c, err, 500)
|
||||
return
|
||||
}
|
||||
targetMountPath, err := op.GetStorageVirtualMountPath(dstPath)
|
||||
if err != nil {
|
||||
common.ErrorResp(c, err, 500)
|
||||
return
|
||||
}
|
||||
if parentMountPath != targetMountPath {
|
||||
common.ErrorResp(c, errs.PermissionDenied, 403)
|
||||
return
|
||||
}
|
||||
overwrite := c.GetHeader("Overwrite") != "false"
|
||||
dstPath := stdpath.Join(path, req.FileName)
|
||||
if !overwrite {
|
||||
res, err := fs.Get(c.Request.Context(), dstPath, &fs.GetArgs{NoLog: true})
|
||||
if err != nil && !errs.IsObjectNotFound(err) {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package handles
|
||||
|
||||
import (
|
||||
"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/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InitStatus 返回系统是否已完成初始化(即是否已存在管理员账号)。
|
||||
// 前端据此判断是否跳转到安装向导。
|
||||
func InitStatus(c *gin.Context) {
|
||||
if _, err := op.GetAdmin(); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.SuccessResp(c, gin.H{"initialized": false})
|
||||
return
|
||||
}
|
||||
common.ErrorResp(c, err, 500, true)
|
||||
return
|
||||
}
|
||||
common.SuccessResp(c, gin.H{"initialized": true})
|
||||
}
|
||||
|
||||
// InitSetupReq 系统初始化请求体
|
||||
type InitSetupReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
SiteTitle string `json:"site_title"`
|
||||
}
|
||||
|
||||
// InitSetup 执行系统初始化:创建管理员账号并设置站点名称等初始参数。
|
||||
// 仅在系统尚未初始化时允许调用;已初始化则返回错误。
|
||||
func InitSetup(c *gin.Context) {
|
||||
var req InitSetupReq
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
common.ErrorResp(c, err, 400)
|
||||
return
|
||||
}
|
||||
// 已初始化则拒绝
|
||||
if _, err := op.GetAdmin(); err == nil {
|
||||
common.ErrorStrResp(c, "system has already been initialized", 400)
|
||||
return
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ErrorResp(c, err, 500, true)
|
||||
return
|
||||
}
|
||||
// 密码最小长度校验
|
||||
if len(req.Password) < 4 {
|
||||
common.ErrorStrResp(c, "password must be at least 4 characters", 400)
|
||||
return
|
||||
}
|
||||
admin := &model.User{
|
||||
Username: req.Username,
|
||||
Role: model.ADMIN,
|
||||
BasePath: "/",
|
||||
Authn: "[]",
|
||||
Permission: 0x71FF,
|
||||
}
|
||||
admin.SetPassword(req.Password)
|
||||
if err := op.CreateUser(admin); err != nil {
|
||||
common.ErrorResp(c, err, 500, true)
|
||||
return
|
||||
}
|
||||
// 设置站点名称(如果提供)
|
||||
if req.SiteTitle != "" {
|
||||
item := model.SettingItem{
|
||||
Key: conf.SiteTitle,
|
||||
Value: req.SiteTitle,
|
||||
Type: conf.TypeString,
|
||||
Group: model.SITE,
|
||||
Flag: model.PUBLIC,
|
||||
}
|
||||
if err := op.SaveSettingItem(&item); err != nil {
|
||||
common.ErrorResp(c, err, 500, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
common.SuccessResp(c)
|
||||
}
|
||||
+9
-119
@@ -1,7 +1,6 @@
|
||||
package handles
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -23,25 +22,12 @@ import (
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"golang.org/x/oauth2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const stateLength = 16
|
||||
const stateExpire = time.Minute * 5
|
||||
const ssoBindingExpire = time.Minute * 5
|
||||
const ssoBindingCookie = "openlist_sso_binding"
|
||||
const ssoBindingStatePurpose = "sso_binding_state"
|
||||
const ssoBindingProofPurpose = "sso_binding_proof"
|
||||
|
||||
type ssoBindingClaims struct {
|
||||
Purpose string `json:"purpose"`
|
||||
Method string `json:"method"`
|
||||
SessionDigest string `json:"session_digest"`
|
||||
SsoID string `json:"sso_id,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
var stateCache = cache.NewMemCache[string](cache.WithShards[string](stateLength))
|
||||
|
||||
@@ -60,68 +46,6 @@ func verifyState(clientID, ip, state string) bool {
|
||||
return ok && value == ip
|
||||
}
|
||||
|
||||
func ssoBindingSessionDigest(session string) string {
|
||||
digest := sha256.Sum256([]byte(session))
|
||||
return base64.RawURLEncoding.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func parseSSOBindingToken(c *gin.Context, rawToken, purpose string) (*ssoBindingClaims, error) {
|
||||
claims := &ssoBindingClaims{}
|
||||
token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errors.New("invalid SSO binding token algorithm")
|
||||
}
|
||||
return common.SecretKey, nil
|
||||
})
|
||||
if err != nil || !token.Valid || claims.ExpiresAt == nil || claims.Purpose != purpose ||
|
||||
claims.Method != "get_sso_id" || len(claims.SessionDigest) != 43 {
|
||||
return nil, errors.New("invalid or expired SSO binding token")
|
||||
}
|
||||
if (purpose == ssoBindingStatePurpose && claims.SsoID != "") ||
|
||||
(purpose == ssoBindingProofPurpose && claims.SsoID == "") {
|
||||
return nil, errors.New("invalid SSO binding token payload")
|
||||
}
|
||||
session, err := c.Cookie(ssoBindingCookie)
|
||||
if err != nil || ssoBindingSessionDigest(session) != claims.SessionDigest {
|
||||
return nil, errors.New("invalid SSO binding session")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func generateSSOBindingToken(c *gin.Context, purpose, ssoID string) (string, error) {
|
||||
session, err := c.Cookie(ssoBindingCookie)
|
||||
expire := ssoBindingExpire
|
||||
if purpose == ssoBindingStatePurpose {
|
||||
session = random.String(32)
|
||||
expire = stateExpire
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(
|
||||
ssoBindingCookie,
|
||||
session,
|
||||
int((stateExpire+ssoBindingExpire).Seconds()),
|
||||
path.Join(conf.URL.Path, "/api"),
|
||||
"",
|
||||
strings.HasPrefix(common.GetApiUrl(c), "https://"),
|
||||
true,
|
||||
)
|
||||
} else if err != nil {
|
||||
return "", errors.New("missing SSO binding session")
|
||||
}
|
||||
now := time.Now()
|
||||
claims := ssoBindingClaims{
|
||||
Purpose: purpose,
|
||||
Method: "get_sso_id",
|
||||
SessionDigest: ssoBindingSessionDigest(session),
|
||||
SsoID: ssoID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(expire)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(common.SecretKey)
|
||||
}
|
||||
|
||||
func ssoRedirectUri(c *gin.Context, useCompatibility bool, method string) string {
|
||||
if useCompatibility {
|
||||
return common.GetApiUrl(c) + "/api/auth/" + method
|
||||
@@ -150,16 +74,6 @@ func SSOLoginRedirect(c *gin.Context) {
|
||||
urlValues.Add("response_type", "code")
|
||||
urlValues.Add("redirect_uri", redirectUri)
|
||||
urlValues.Add("client_id", clientId)
|
||||
bindingState := ""
|
||||
var err error
|
||||
if method == "get_sso_id" {
|
||||
bindingState, err = generateSSOBindingToken(c, ssoBindingStatePurpose, "")
|
||||
if err != nil {
|
||||
common.ErrorResp(c, err, 500)
|
||||
return
|
||||
}
|
||||
urlValues.Add("state", bindingState)
|
||||
}
|
||||
switch platform {
|
||||
case "Github":
|
||||
rUrl = "https://github.com/login/oauth/authorize?"
|
||||
@@ -180,19 +94,15 @@ func SSOLoginRedirect(c *gin.Context) {
|
||||
endpoint := strings.TrimSuffix(setting.GetStr(conf.SSOEndpointName), "/")
|
||||
rUrl = endpoint + "/login/oauth/authorize?"
|
||||
urlValues.Add("scope", "profile")
|
||||
if bindingState == "" {
|
||||
urlValues.Add("state", endpoint)
|
||||
}
|
||||
urlValues.Add("state", endpoint)
|
||||
case "OIDC":
|
||||
oauth2Config, err := GetOIDCClient(c, useCompatibility, redirectUri, method)
|
||||
if err != nil {
|
||||
common.ErrorStrResp(c, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if bindingState == "" {
|
||||
bindingState = generateState(clientId, c.ClientIP())
|
||||
}
|
||||
c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(bindingState))
|
||||
state := generateState(clientId, c.ClientIP())
|
||||
c.Redirect(http.StatusFound, oauth2Config.AuthCodeURL(state))
|
||||
return
|
||||
default:
|
||||
common.ErrorStrResp(c, "invalid platform", 400)
|
||||
@@ -291,15 +201,11 @@ func OIDCLoginCallback(c *gin.Context) {
|
||||
common.ErrorResp(c, err, 400)
|
||||
return
|
||||
}
|
||||
if method == "get_sso_id" {
|
||||
if _, err := parseSSOBindingToken(c, c.Query("state"), ssoBindingStatePurpose); err != nil {
|
||||
common.ErrorStrResp(c, "incorrect or expired state parameter", 400)
|
||||
return
|
||||
}
|
||||
} else if !verifyState(clientId, c.ClientIP(), c.Query("state")) {
|
||||
if !verifyState(clientId, c.ClientIP(), c.Query("state")) {
|
||||
common.ErrorStrResp(c, "incorrect or expired state parameter", 400)
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Token, err := oauth2Config.Exchange(c, c.Query("code"))
|
||||
if err != nil {
|
||||
common.ErrorResp(c, err, 400)
|
||||
@@ -329,13 +235,8 @@ func OIDCLoginCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if method == "get_sso_id" {
|
||||
bindingProof, err := generateSSOBindingToken(c, ssoBindingProofPurpose, userID)
|
||||
if err != nil {
|
||||
common.ErrorResp(c, err, 500)
|
||||
return
|
||||
}
|
||||
if useCompatibility {
|
||||
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+bindingProof)
|
||||
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID)
|
||||
return
|
||||
}
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
@@ -345,7 +246,7 @@ func OIDCLoginCallback(c *gin.Context) {
|
||||
window.opener.postMessage({"sso_id": "%s"}, "*")
|
||||
window.close()
|
||||
</script>
|
||||
</body>`, bindingProof)
|
||||
</body>`, userID)
|
||||
c.Data(200, "text/html; charset=utf-8", []byte(html))
|
||||
return
|
||||
}
|
||||
@@ -451,12 +352,6 @@ func SSOLoginCallback(c *gin.Context) {
|
||||
common.ErrorStrResp(c, "No code provided", 400)
|
||||
return
|
||||
}
|
||||
if argument == "get_sso_id" {
|
||||
if _, err := parseSSOBindingToken(c, c.Query("state"), ssoBindingStatePurpose); err != nil {
|
||||
common.ErrorStrResp(c, "incorrect or expired state parameter", 400)
|
||||
return
|
||||
}
|
||||
}
|
||||
var resp *resty.Response
|
||||
var err error
|
||||
if platform == "Dingtalk" {
|
||||
@@ -507,13 +402,8 @@ func SSOLoginCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if argument == "get_sso_id" {
|
||||
bindingProof, err := generateSSOBindingToken(c, ssoBindingProofPurpose, userID)
|
||||
if err != nil {
|
||||
common.ErrorResp(c, err, 500)
|
||||
return
|
||||
}
|
||||
if usecompatibility {
|
||||
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+bindingProof)
|
||||
c.Redirect(302, common.GetApiUrl(c)+"/@manage?sso_id="+userID)
|
||||
return
|
||||
}
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
@@ -523,7 +413,7 @@ func SSOLoginCallback(c *gin.Context) {
|
||||
window.opener.postMessage({"sso_id": "%s"}, "*")
|
||||
window.close()
|
||||
</script>
|
||||
</body>`, bindingProof)
|
||||
</body>`, userID)
|
||||
c.Data(200, "text/html; charset=utf-8", []byte(html))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func needSign(meta *model.Meta, path string) bool {
|
||||
if meta == nil || meta.Password == "" {
|
||||
return false
|
||||
}
|
||||
if !meta.PSub && !common.MetaCoversPath(meta.Path, path, false) {
|
||||
if !meta.PSub && path != meta.Path {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
+1
-3
@@ -102,8 +102,6 @@ func Init(e *gin.Engine) {
|
||||
public.Any("/settings", handles.PublicSettings)
|
||||
public.Any("/offline_download_tools", handles.OfflineDownloadTools)
|
||||
public.Any("/archive_extensions", handles.ArchiveExtensions)
|
||||
public.Any("/init_status", handles.InitStatus)
|
||||
public.POST("/init/setup", handles.InitSetup)
|
||||
|
||||
_fs(auth.Group("/fs"))
|
||||
fsAndShare(api.Group("/fs", middlewares.Auth(true)))
|
||||
@@ -235,7 +233,7 @@ func _fs(g *gin.RouterGroup) {
|
||||
g.POST("/torrent/rapid_upload", handles.TorrentRapidUpload)
|
||||
g.POST("/torrent/generate", handles.GenerateTorrentForPath)
|
||||
// Direct upload (client-side upload to storage)
|
||||
g.POST("/get_direct_upload_info", handles.FsGetDirectUploadInfo)
|
||||
g.POST("/get_direct_upload_info", middlewares.FsUp, handles.FsGetDirectUploadInfo)
|
||||
}
|
||||
|
||||
func _task(g *gin.RouterGroup) {
|
||||
|
||||
+24
-68
@@ -4,11 +4,9 @@ package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -32,7 +30,7 @@ import (
|
||||
|
||||
var (
|
||||
emptyPrefix = &gofakes3.Prefix{}
|
||||
timeFormat = http.TimeFormat
|
||||
timeFormat = "Mon, 2 Jan 2006 15:04:05 GMT"
|
||||
)
|
||||
|
||||
// s3Backend implements the gofakes3.Backend interface to make an S3
|
||||
@@ -119,13 +117,9 @@ func (b *s3Backend) HeadObject(ctx context.Context, bucketName, objectName strin
|
||||
|
||||
fp := path.Join(bucketPath, objectName)
|
||||
fmeta, _ := op.GetNearestMeta(fp)
|
||||
ctx = context.WithValue(ctx, conf.MetaKey, fmeta)
|
||||
node, err := fs.Get(ctx, fp, &fs.GetArgs{})
|
||||
node, err := fs.Get(context.WithValue(ctx, conf.MetaKey, fmeta), fp, &fs.GetArgs{})
|
||||
if err != nil {
|
||||
if errs.IsObjectNotFound(err) {
|
||||
return nil, gofakes3.KeyNotFound(objectName)
|
||||
}
|
||||
return nil, err
|
||||
return nil, gofakes3.KeyNotFound(objectName)
|
||||
}
|
||||
|
||||
if node.IsDir() {
|
||||
@@ -133,27 +127,23 @@ func (b *s3Backend) HeadObject(ctx context.Context, bucketName, objectName strin
|
||||
}
|
||||
|
||||
size := node.GetSize()
|
||||
hash, err := getObjectHash(ctx, fp, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// hash := getFileHashByte(fobj)
|
||||
|
||||
meta := map[string]string{
|
||||
"Last-Modified": node.ModTime().UTC().Format(timeFormat),
|
||||
"Last-Modified": node.ModTime().Format(timeFormat),
|
||||
"Content-Type": utils.GetMimeType(fp),
|
||||
}
|
||||
|
||||
stored, etag := b.loadMetadata(fp, hash)
|
||||
for k, v := range stored {
|
||||
if k != "Last-Modified" {
|
||||
if val, ok := b.meta.Load(fp); ok {
|
||||
metaMap := val.(map[string]string)
|
||||
for k, v := range metaMap {
|
||||
meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return &gofakes3.Object{
|
||||
Name: objectName,
|
||||
Hash: hash,
|
||||
ETag: etag,
|
||||
Name: objectName,
|
||||
// Hash: hash,
|
||||
Metadata: meta,
|
||||
Size: size,
|
||||
Contents: noOpReadCloser{},
|
||||
@@ -170,24 +160,15 @@ func (b *s3Backend) GetObject(ctx context.Context, bucketName, objectName string
|
||||
|
||||
fp := path.Join(bucketPath, objectName)
|
||||
fmeta, _ := op.GetNearestMeta(fp)
|
||||
ctx = context.WithValue(ctx, conf.MetaKey, fmeta)
|
||||
node, err := fs.Get(ctx, fp, &fs.GetArgs{})
|
||||
node, err := fs.Get(context.WithValue(ctx, conf.MetaKey, fmeta), fp, &fs.GetArgs{})
|
||||
if err != nil {
|
||||
if errs.IsObjectNotFound(err) {
|
||||
return nil, gofakes3.KeyNotFound(objectName)
|
||||
}
|
||||
return nil, err
|
||||
return nil, gofakes3.KeyNotFound(objectName)
|
||||
}
|
||||
|
||||
if node.IsDir() {
|
||||
return nil, gofakes3.KeyNotFound(objectName)
|
||||
}
|
||||
|
||||
hash, err := getObjectHash(ctx, fp, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
link, file, err := fs.Link(ctx, fp, model.LinkArgs{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -212,7 +193,7 @@ func (b *s3Backend) GetObject(ctx context.Context, bucketName, objectName string
|
||||
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support s3")
|
||||
}
|
||||
|
||||
var rd io.ReadCloser
|
||||
var rd io.Reader
|
||||
if rnge != nil {
|
||||
rd, err = rrf.RangeRead(ctx, http_range.Range(*rnge))
|
||||
} else {
|
||||
@@ -223,34 +204,26 @@ func (b *s3Backend) GetObject(ctx context.Context, bucketName, objectName string
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
"Last-Modified": node.ModTime().UTC().Format(timeFormat),
|
||||
"Last-Modified": node.ModTime().Format(timeFormat),
|
||||
"Content-Disposition": utils.GenerateContentDisposition(file.GetName()),
|
||||
"Content-Type": utils.GetMimeType(fp),
|
||||
}
|
||||
|
||||
stored, etag := b.loadMetadata(fp, hash)
|
||||
for k, v := range stored {
|
||||
if k != "Last-Modified" {
|
||||
if val, ok := b.meta.Load(fp); ok {
|
||||
metaMap := val.(map[string]string)
|
||||
for k, v := range metaMap {
|
||||
meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return &gofakes3.Object{
|
||||
// Name: gofakes3.URLEncode(objectName),
|
||||
Name: objectName,
|
||||
Hash: hash,
|
||||
ETag: etag,
|
||||
Name: objectName,
|
||||
// Hash: "",
|
||||
Metadata: meta,
|
||||
Size: size,
|
||||
Range: rnge,
|
||||
Contents: utils.NewReadCloser(rd, func() error {
|
||||
readErr := rd.Close()
|
||||
linkErr := link.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
return linkErr
|
||||
}),
|
||||
Contents: utils.ReadCloser{Reader: rd, Closer: link},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -266,7 +239,7 @@ func (b *s3Backend) PutObject(
|
||||
meta map[string]string,
|
||||
input io.Reader, size int64,
|
||||
) (result gofakes3.PutObjectResult, err error) {
|
||||
return result, b.putStream(ctx, bucketName, objectName, meta, input, size, "")
|
||||
return result, b.putStream(ctx, bucketName, objectName, meta, input, size)
|
||||
}
|
||||
|
||||
// putStream stores the given object into the underlying storage. It is shared
|
||||
@@ -276,7 +249,6 @@ func (b *s3Backend) putStream(
|
||||
ctx context.Context, bucketName, objectName string,
|
||||
meta map[string]string,
|
||||
input io.Reader, size int64,
|
||||
etag string,
|
||||
) error {
|
||||
bucket, err := getBucketByName(bucketName)
|
||||
if err != nil {
|
||||
@@ -342,22 +314,18 @@ func (b *s3Backend) putStream(
|
||||
if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) {
|
||||
return errs.IgnoredSystemFile
|
||||
}
|
||||
hash := md5.New()
|
||||
stream := &stream.FileStream{
|
||||
Obj: &obj,
|
||||
Reader: io.TeeReader(input, hash),
|
||||
Reader: input,
|
||||
Mimetype: meta["Content-Type"],
|
||||
}
|
||||
if stream.Mimetype == "" {
|
||||
stream.Mimetype = "application/octet-stream"
|
||||
}
|
||||
|
||||
err = fs.PutDirectly(ctx, reqPath, stream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.meta.Store(fp, objectMetadata{headers: meta, hash: hash.Sum(nil), etag: etag})
|
||||
b.meta.Store(fp, meta)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -403,10 +371,7 @@ func (b *s3Backend) deleteObject(ctx context.Context, bucketName, objectName str
|
||||
return err
|
||||
}
|
||||
|
||||
if err := fs.Remove(ctx, fp); err != nil {
|
||||
return err
|
||||
}
|
||||
b.meta.Delete(fp)
|
||||
fs.Remove(ctx, fp)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -450,18 +415,9 @@ func (b *s3Backend) CopyObject(ctx context.Context, srcBucket, srcKey, dstBucket
|
||||
srcFp := path.Join(srcBucketPath, srcKey)
|
||||
fmeta, _ := op.GetNearestMeta(srcFp)
|
||||
srcNode, err := fs.Get(context.WithValue(ctx, conf.MetaKey, fmeta), srcFp, &fs.GetArgs{})
|
||||
if err != nil {
|
||||
if errs.IsObjectNotFound(err) {
|
||||
return result, gofakes3.KeyNotFound(srcKey)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
c, err := b.GetObject(ctx, srcBucket, srcKey, nil)
|
||||
if err != nil {
|
||||
if errs.IsObjectNotFound(err) {
|
||||
return result, gofakes3.KeyNotFound(srcKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/OpenListTeam/gofakes3"
|
||||
)
|
||||
|
||||
func TestCopyObjectMissingSourceReturnsNoSuchKey(t *testing.T) {
|
||||
b, _ := setupMultipartBackend(t)
|
||||
|
||||
_, err := b.CopyObject(context.Background(), "mp", "missing.txt", "mp", "copy.txt", nil)
|
||||
if code := s3ErrorCode(err); code != gofakes3.ErrNoSuchKey {
|
||||
t.Fatalf("CopyObject() error = %v, want NoSuchKey", code)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/fs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
|
||||
type objectMetadata struct {
|
||||
headers map[string]string
|
||||
hash []byte
|
||||
etag string
|
||||
}
|
||||
|
||||
// Only reuse upload metadata when it still describes the current content.
|
||||
func (b *s3Backend) loadMetadata(name string, hash []byte) (map[string]string, string) {
|
||||
if value, ok := b.meta.Load(name); ok {
|
||||
metadata := value.(objectMetadata)
|
||||
if bytes.Equal(metadata.hash, hash) {
|
||||
return metadata.headers, metadata.etag
|
||||
}
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Metadata alone cannot identify content: clients can preserve both file size
|
||||
// and modification time when overwriting an object. Use the driver's MD5 when
|
||||
// available, otherwise hash the complete content, including for HEAD and ranges.
|
||||
func getObjectHash(ctx context.Context, name string, obj model.Obj) ([]byte, error) {
|
||||
if value := obj.GetHash().GetHash(utils.MD5); value != "" {
|
||||
hash, err := hex.DecodeString(value)
|
||||
if err != nil || len(hash) != md5.Size {
|
||||
return nil, fmt.Errorf("invalid object MD5: %q", value)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
link, file, err := fs.Link(ctx, name, model.LinkArgs{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer link.Close()
|
||||
size := link.ContentLength
|
||||
if size <= 0 {
|
||||
size = file.GetSize()
|
||||
}
|
||||
ranges, err := stream.GetRangeReaderFromLink(size, link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, err := ranges.RangeRead(ctx, http_range.Range{Length: -1})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
hash := md5.New()
|
||||
n, err := utils.CopyWithBuffer(hash, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n != size {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
return hash.Sum(nil), nil
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
|
||||
func TestObjectHashFromDriver(t *testing.T) {
|
||||
sum := md5.Sum([]byte("content"))
|
||||
obj := &model.Object{HashInfo: utils.NewHashInfo(utils.MD5, hex.EncodeToString(sum[:]))}
|
||||
hash, err := getObjectHash(context.Background(), "object", obj)
|
||||
if err != nil || hex.EncodeToString(hash) != hex.EncodeToString(sum[:]) {
|
||||
t.Fatalf("hash = %x, err = %v", hash, err)
|
||||
}
|
||||
for _, value := range []string{"invalid", "ab"} {
|
||||
obj.HashInfo = utils.NewHashInfo(utils.MD5, value)
|
||||
if _, err := getObjectHash(context.Background(), "object", obj); err == nil {
|
||||
t.Fatalf("accepted invalid MD5 %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartMetadataMatchesCurrentContent(t *testing.T) {
|
||||
b := &s3Backend{meta: new(sync.Map)}
|
||||
oldHash := md5.Sum([]byte("old"))
|
||||
newHash := md5.Sum([]byte("new"))
|
||||
b.meta.Store("object", objectMetadata{
|
||||
headers: map[string]string{"Content-Type": "text/plain"},
|
||||
hash: oldHash[:],
|
||||
etag: `"multipart-2"`,
|
||||
})
|
||||
meta, etag := b.loadMetadata("object", oldHash[:])
|
||||
if etag != `"multipart-2"` || meta["Content-Type"] != "text/plain" {
|
||||
t.Fatal("lost metadata for unchanged multipart content")
|
||||
}
|
||||
meta, etag = b.loadMetadata("object", newHash[:])
|
||||
if meta != nil || etag != "" {
|
||||
t.Fatal("reused a multipart validator after a same-size content change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionalRequestsDoNotRedirect(t *testing.T) {
|
||||
for _, method := range []string{"GET", "PUT"} {
|
||||
for _, name := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since", "If-Range"} {
|
||||
for _, value := range []string{"*", ""} {
|
||||
r := httptest.NewRequest(method, "/bucket/object", nil)
|
||||
r.Header.Set(name, value)
|
||||
if url, ok := directObjectURL(r, nil); ok || url != "" {
|
||||
t.Fatalf("redirected %s with %s", method, name)
|
||||
}
|
||||
if url, ok := directUploadURL(r, nil); ok || url != "" {
|
||||
t.Fatalf("redirected %s with %s", method, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,9 +234,7 @@ func (b *s3Backend) CompleteMultipartUpload(ctx context.Context, bucket, object
|
||||
|
||||
defer combined.Close()
|
||||
|
||||
sum := md5.Sum(concat)
|
||||
etag := fmt.Sprintf("%q", fmt.Sprintf("%s-%d", hex.EncodeToString(sum[:]), len(ordered)))
|
||||
err := b.putStream(ctx, bucket, object, state.meta, combined, total, etag)
|
||||
err := b.putStream(ctx, bucket, object, state.meta, combined, total)
|
||||
if err != nil {
|
||||
// Leave the upload in place so the client may retry completion, per
|
||||
// the gofakes3 MultipartBackend contract.
|
||||
@@ -246,6 +244,8 @@ func (b *s3Backend) CompleteMultipartUpload(ctx context.Context, bucket, object
|
||||
// Success: drop bookkeeping and clean up part files.
|
||||
b.removeUpload(uploadID)
|
||||
|
||||
sum := md5.Sum(concat)
|
||||
etag := fmt.Sprintf("%q", fmt.Sprintf("%s-%d", hex.EncodeToString(sum[:]), len(ordered)))
|
||||
log.Debugf("s3 multipart: completed upload %s -> %s/%s (%d bytes)", uploadID, bucket, object, total)
|
||||
return "", etag, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package s3
|
||||
|
||||
import "net/http"
|
||||
|
||||
func hasPreconditions(r *http.Request) bool {
|
||||
for _, name := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since", "If-Range"} {
|
||||
if _, ok := r.Header[name]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package s3
|
||||
|
||||
import "net/http"
|
||||
|
||||
// gofakes3 sets Content-Range without selecting the partial-content status.
|
||||
func rangeStatusHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.Header.Get("Range") == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
rw := &rangeResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(rw, r)
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type rangeResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *rangeResponseWriter) WriteHeader(status int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
}
|
||||
if status >= 200 {
|
||||
w.wroteHeader = true
|
||||
}
|
||||
if status == http.StatusOK && w.Header().Get("Content-Range") != "" {
|
||||
status = http.StatusPartialContent
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *rangeResponseWriter) Write(p []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
func (w *rangeResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRangeStatusHandler(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
method string
|
||||
rangeHeader string
|
||||
contentRange string
|
||||
status int
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"partial", "GET", "bytes=2-5", "bytes 2-5/16", 0, "2345", 206},
|
||||
{"explicit OK", "GET", "bytes=2-5", "bytes 2-5/16", 200, "2345", 206},
|
||||
{"already partial", "GET", "bytes=2-5", "bytes 2-5/16", 206, "2345", 206},
|
||||
{"full", "GET", "", "", 0, "0123456789abcdef", 200},
|
||||
{"range ignored", "GET", "bytes=2-5", "", 0, "0123456789abcdef", 200},
|
||||
{"not modified", "GET", "bytes=2-5", "", 304, "", 304},
|
||||
{"invalid range", "GET", "bytes=20-", "bytes */16", 416, "error", 416},
|
||||
{"forbidden", "GET", "bytes=2-5", "", 403, "error", 403},
|
||||
{"empty body", "GET", "bytes=2-5", "bytes 2-5/16", 0, "", 206},
|
||||
{"head", "HEAD", "bytes=2-5", "", 0, "", 200},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tt.contentRange != "" {
|
||||
w.Header().Set("Content-Range", tt.contentRange)
|
||||
}
|
||||
if tt.status != 0 {
|
||||
w.WriteHeader(tt.status)
|
||||
}
|
||||
if tt.body != "" {
|
||||
_, _ = io.WriteString(w, tt.body)
|
||||
}
|
||||
})
|
||||
r := httptest.NewRequest(tt.method, "/bucket/object", nil)
|
||||
r.Header.Set("Range", tt.rangeHeader)
|
||||
w := httptest.NewRecorder()
|
||||
rangeStatusHandler(next).ServeHTTP(w, r)
|
||||
if w.Code != tt.want {
|
||||
t.Fatalf("status = %d, want %d", w.Code, tt.want)
|
||||
}
|
||||
if w.Body.String() != tt.body || w.Header().Get("Content-Range") != tt.contentRange {
|
||||
t.Fatalf("response changed: body=%q, range=%q", w.Body.String(), w.Header().Get("Content-Range"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func redirectHandler(next http.Handler, authPairs map[string]string) http.Handle
|
||||
}
|
||||
|
||||
func directObjectURL(r *http.Request, authPairs map[string]string) (string, bool) {
|
||||
if r.Method != http.MethodGet || hasPreconditions(r) {
|
||||
if r.Method != http.MethodGet {
|
||||
return "", false
|
||||
}
|
||||
if hasNonObjectQuery(r) || !s3RequestAuthorized(r, authPairs) {
|
||||
@@ -75,7 +75,7 @@ func directObjectURL(r *http.Request, authPairs map[string]string) (string, bool
|
||||
}
|
||||
|
||||
func directUploadURL(r *http.Request, authPairs map[string]string) (string, bool) {
|
||||
if r.Method != http.MethodPut || r.ContentLength < 0 || hasPreconditions(r) {
|
||||
if r.Method != http.MethodPut || r.ContentLength < 0 {
|
||||
return "", false
|
||||
}
|
||||
if hasNonObjectQuery(r) || !s3RequestAuthorized(r, authPairs) {
|
||||
|
||||
+1
-1
@@ -24,5 +24,5 @@ func NewServer(ctx context.Context) (h http.Handler, err error) {
|
||||
gofakes3.WithIntegrityCheck(true), // Check Content-MD5 if supplied
|
||||
)
|
||||
|
||||
return redirectHandler(rangeStatusHandler(faker.Server()), authPairs), nil
|
||||
return redirectHandler(faker.Server(), authPairs), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user