Compare commits

..

13 Commits

497 changed files with 8500 additions and 38029 deletions
+8 -17
View File
@@ -13,7 +13,7 @@ body:
attributes:
label: 请确认以下事项
description: |
您必须阅读、检查、确认、同意以下内容,否则您的问题一定会被直接关闭。
您必须勾选以下内容,否则您的问题可能会被直接关闭。
或者您可以去[讨论区](https://github.com/OpenListTeam/OpenList/discussions)。
options:
- label: |
@@ -34,8 +34,7 @@ body:
我认为此问题必须由`OpenList`处理,而非第三方。
- label: |
我已确认这个问题在最新版本中没有被修复。
- label: |
我没有阅读这个清单,只是闭眼选中了所有的复选框,请关闭这个 Issue 。
- type: input
id: version
attributes:
@@ -60,14 +59,6 @@ body:
label: 问题描述(必填)
validations:
required: true
- type: textarea
id: logs
attributes:
label: 日志(必填)
description: |
请复制粘贴错误日志,或者截图。(可隐藏隐私字段) [查看方法](https://doc.oplist.org/faq/howto#%E5%A6%82%E4%BD%95%E5%BF%AB%E9%80%9F%E5%AE%9A%E4%BD%8Dbug)
validations:
required: true
- type: textarea
id: config
attributes:
@@ -76,15 +67,15 @@ body:
请提供您的`OpenList`应用的配置文件,并截图相关存储配置。(可隐藏隐私字段)
validations:
required: true
- type: textarea
id: logs
attributes:
label: 日志(可选)
description: |
请复制粘贴错误日志,或者截图。(可隐藏隐私字段) [查看方法](https://doc.oplist.org/faq/howto#%E5%A6%82%E4%BD%95%E5%BF%AB%E9%80%9F%E5%AE%9A%E4%BD%8Dbug)
- type: textarea
id: reproduction
attributes:
label: 复现链接(可选)
description: |
请提供能复现此问题的链接。
- type: textarea
id: aigenerated
attributes:
label: AI生成内容(可选)
description: |
如果此问题是由AI辅助您发现的,请提供全部聊天记录,包括使用的模型信息。
+8 -17
View File
@@ -13,7 +13,7 @@ body:
attributes:
label: Please confirm the following
description: |
You must read, check, confirm, and agree to all the following, otherwise your issue will definitely be closed directly.
You must check all the following, otherwise your issue may be closed directly.
Or you can go to the [discussions](https://github.com/OpenListTeam/OpenList/discussions).
options:
- label: |
@@ -34,8 +34,7 @@ body:
I believe this issue must be handled by `OpenList` and not by a third party.
- label: |
I confirm this issue is not fixed in the latest version.
- label: |
I have not read these checkboxes and therefore I just ticked them all, Please close this issue.
- type: input
id: version
attributes:
@@ -60,14 +59,6 @@ body:
label: Bug Description (required)
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs (required)
description: |
Please copy and paste any relevant log output or screenshots. (You may mask sensitive fields) [Guide](https://doc.oplist.org/faq/howto#how-to-quickly-locate-bugs)
validations:
required: true
- type: textarea
id: config
attributes:
@@ -76,15 +67,15 @@ body:
Please provide your `OpenList` application's configuration file and a screenshot of the relevant storage configuration. (You may mask sensitive fields)
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs (optional)
description: |
Please copy and paste any relevant log output or screenshots. (You may mask sensitive fields) [Guide](https://doc.oplist.org/faq/howto#how-to-quickly-locate-bugs)
- type: textarea
id: reproduction
attributes:
label: Reproduction Link (optional)
description: |
Please provide a link to a repo or page that can reproduce this issue.
- type: textarea
id: aigenerated
attributes:
label: AI Generated Content (optional)
description: |
If this issue was identified with the assistance of AI, please provide the complete chat log, including information about the model used.
@@ -7,7 +7,7 @@ body:
attributes:
label: 请确认以下事项
description: |
您必须阅读、检查、确认、同意以下内容,否则您的问题可能会被直接关闭。
您必须勾选以下内容,否则您的问题可能会被直接关闭。
或者您可以去[讨论区](https://github.com/OpenListTeam/OpenList/discussions)。
options:
- label: |
@@ -28,8 +28,6 @@ body:
我已确认此功能尚未被实现。
- label: |
我已确认此功能是合理的,且有普遍需求,并非我个人需要。
- label: |
我没有阅读这个清单,只是闭眼选中了所有的复选框,请关闭这个 Issue 。
- type: textarea
id: feature-description
attributes:
@@ -48,9 +46,3 @@ body:
label: 附加信息
description: |
相关的任何其他上下文或截图,或者你觉得有帮助的信息
- type: textarea
id: aigenerated
attributes:
label: AI生成内容(可选)
description: |
如果此请求是由AI辅助您提交的,请提供全部聊天记录,包括使用的模型信息。
@@ -1,13 +1,13 @@
name: "Feature Request"
description: Feature Request / Enhancement
title: "[Feature] Please modify the title to your feature name"
title: "[Feature] Please change the title to your feature name"
labels: [enhancement]
body:
- type: checkboxes
attributes:
label: Please confirm the following
description: |
You must read, check, confirm, and agree to all the following, otherwise your request may be closed directly.
You must check all the following, otherwise your request may be closed directly.
Or you can go to the [discussions](https://github.com/OpenListTeam/OpenList/discussions).
options:
- label: |
@@ -28,8 +28,6 @@ body:
I confirm this feature has not been implemented yet.
- label: |
I confirm this feature is reasonable and has general demand, not just my personal need.
- label: |
I have not read these checkboxes and therefore I just ticked them all, Please close this issue.
- type: textarea
id: feature-description
attributes:
@@ -48,9 +46,3 @@ body:
label: Additional Information
description: |
Any other context or screenshots related to this feature request, or information you find helpful.
- type: textarea
id: aigenerated
attributes:
label: AI Generated Content (optional)
description: |
If this request was submitted with the assistance of an AI, please provide the complete chat log, including information about the model used.
+39 -97
View File
@@ -1,114 +1,56 @@
<!--
PR title / PR 标题:
- Use Conventional Commits: `type(scope): summary`
- Allowed types: `feat`, `docs`, `fix`, `style`, `refactor`, `chore`
- Scope is required by the current PR title check.
- For breaking changes, add `!`: `feat(driver)!: change auth flow`
Provide a general summary of your changes in the Title above.
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.
-->
<!--
在上方标题中提供您更改的总体摘要。
PR 标题需以 `feat(): `, `docs(): `, `fix(): `, `style(): `, `refactor(): `, `chore(): ` 其中之一开头,例如:`feat(component): 新增功能`。
如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。
-->
## Summary / 摘要
## Description / 描述
<!--
Briefly describe what changed and why.
简要说明改了什么,以及为什么需要改。
-->
<!-- Describe your changes in detail -->
<!-- 详细描述您的更改 -->
<!--
- List user-visible behavior changes.
- List important implementation changes.
- Mention config, storage, API, or compatibility changes if any.
## Motivation and Context / 背景
- 列出用户可感知的行为变化。
- 列出重要实现变化。
- 如涉及配置、存储、API 或兼容性变化,请明确说明。
-->
<!-- Why is this change required? What problem does it solve? -->
<!-- 为什么需要此更改?它解决了什么问题? -->
- [ ] This PR has breaking changes.
/ 此 PR 包含破坏性变更。
- [ ] This PR changes public API, config, storage format, or migration behavior.
/ 此 PR 修改了公开 API、配置、存储格式或迁移行为。
- [ ] This PR requires corresponding changes in related repositories.
/ 此 PR 需要关联仓库同步修改。
<!-- If it fixes an open issue, please link to the issue here. -->
<!-- 如果修复了一个打开的issue,请在此处链接到该issue -->
Related repository PRs / 关联仓库 PR:
Closes #XXXX
- OpenList-Frontend:
- OpenList-Docs:
<!-- or -->
<!-- 或者 -->
## Related Issues / 关联 Issue
Relates to #XXXX
<!--
Use `Closes #123`, `Fixes #123`, or `Relates to #123`.
Remove this section if not applicable.
使用 `Closes #123`、`Fixes #123` 或 `Relates to #123`。
不适用时请删除本节。
-->
## How Has This Been Tested? / 测试
## Testing / 测试
<!--
Describe commands, platforms, and manual checks.
If not tested, explain why.
说明执行过的命令、测试平台和手动验证。
如果未测试,请说明原因。
-->
- [ ] `go test ./...`
- [ ] Manual test / 手动测试:
<!-- Please describe in detail how you tested your changes. -->
<!-- 请详细描述您如何测试更改 -->
## Checklist / 检查清单
- [ ] I have read [CONTRIBUTING](https://github.com/OpenListTeam/OpenList/blob/main/CONTRIBUTING.md).
/ 我已阅读 [CONTRIBUTING](https://github.com/OpenListTeam/OpenList/blob/main/CONTRIBUTING.md)。
- [ ] I confirm this contribution follows the repository license, contribution policy, and code of conduct.
/ 我确认此贡献符合仓库许可证、贡献规范和行为准则。
- [ ] I have formatted the changed code with `gofmt`, `go fmt`, or `prettier` where applicable.
/ 我已按适用情况使用 `gofmt`、`go fmt` 或 `prettier` 格式化变更代码。
- [ ] I have requested review from relevant maintainers or code owners where applicable.
/ 我已在适用情况下请求相关维护者或代码所有者审查。
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!-- 检查以下所有要点,并在所有适用的框中打`x` -->
## AI Disclosure / AI 使用声明
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
<!-- 如果您对其中任何一项不确定,请不要犹豫提问。我们会帮助您! -->
<!--
Please disclose any substantial AI assistance used in this PR.
Minor AI assistance, such as typo fixes, autocomplete, formatting suggestions,
or wording polish, does not need to be disclosed.
Remove this section if not applicable.
请披露此 PR 中使用的重要 AI 辅助内容。
轻微 AI 辅助,例如拼写修正、自动补全、格式建议或文字润色,无需披露。
如不适用,请删除本节。
Deliberate non-disclosure may be treated as a trust and compliance issue.
故意隐瞒 AI 使用情况可能被视为信任与合规问题。
-->
- [ ] This PR includes AI-assisted content.
/ 此 PR 包含 AI 辅助内容。
Tools used / 使用工具:
- [ ] ChatGPT
- [ ] Codex
- [ ] GitHub Copilot
- [ ] Claude
- [ ] Gemini
- [ ] Other (please specify) / 其他(请注明):
Usage scope / 使用范围:
- [ ] Code generation / 代码生成
- [ ] Refactoring / 重构
- [ ] Documentation / 文档
- [ ] Tests / 测试
- [ ] Translation / 翻译
- [ ] Review assistance / 审查辅助
- [ ] I have reviewed and validated all AI-assisted content included in this PR.
/ 我已审核并验证此 PR 中的所有 AI 辅助内容。
- [ ] I have ensured that all AI-assisted commits include `Co-Authored-By` attribution.
/ 我已确保所有 AI 辅助提交都包含 `Co-Authored-By` 归属信息。
- [ ] I can reproduce all AI-assisted content included in this PR without any AI tools.
/ 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。
- [ ] I have read the [CONTRIBUTING](https://github.com/OpenListTeam/OpenList/blob/main/CONTRIBUTING.md) document.
我已阅读 [CONTRIBUTING](https://github.com/OpenListTeam/OpenList/blob/main/CONTRIBUTING.md) 文档。
- [ ] I have formatted my code with `go fmt` or [prettier](https://prettier.io/).
我已使用 `go fmt` 或 [prettier](https://prettier.io/) 格式化提交的代码。
- [ ] I have added appropriate labels to this PR (or mentioned needed labels in the description if lacking permissions).
我已为此 PR 添加了适当的标签(如无权限或需要的标签不存在,请在描述中说明,管理员将后续处理)。
- [ ] I have requested review from relevant code authors using the "Request review" feature when applicable.
我已在适当情况下使用"Request review"功能请求相关代码作者进行审查。
- [ ] I have updated the repository accordingly (If it’s needed).
我已相应更新了相关仓库(若适用)。
- [ ] [OpenList-Frontend](https://github.com/OpenListTeam/OpenList-Frontend) #XXXX
- [ ] [OpenList-Docs](https://github.com/OpenListTeam/OpenList-Docs) #XXXX
-153
View File
@@ -1,153 +0,0 @@
name: Beta Media Release builds
on:
push:
branches: ["dev-media"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
changelog:
name: Beta Media Release Changelog
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create or update ref
id: create-or-update-ref
uses: ovsds/create-or-update-ref-action@v1
with:
ref: tags/beta-media
sha: ${{ github.sha }}
- name: Delete beta-media tag
run: git tag -d beta-media
continue-on-error: true
- name: changelog
id: changelog
run: |
git tag -l
npx changelogithub --output CHANGELOG.md
- name: Upload assets to beta-media release
uses: softprops/action-gh-release@v2
with:
body_path: CHANGELOG.md
files: CHANGELOG.md
prerelease: true
tag_name: beta-media
- name: Upload assets to github artifact
uses: actions/upload-artifact@v4
with:
name: beta-media changelog
path: ${{ github.workspace }}/CHANGELOG.md
compression-level: 0
if-no-files-found: error
release:
needs:
- changelog
strategy:
matrix:
include:
- target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch
hash: "md5"
- target: "linux-!(arm*)-musl*" #musl-not-arm
hash: "md5-linux-musl"
- target: "linux-arm*-musl*" #musl-arm
hash: "md5-linux-musl-arm"
- target: "windows-arm64" #win-arm64
hash: "md5-windows-arm64"
- target: "windows7-*" #win7
hash: "md5-windows7"
- target: "android-*" #android
hash: "md5-android"
- target: "freebsd-*" #freebsd
hash: "md5-freebsd"
name: Beta Media Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25.0"
- name: Setup web
run: |
frontendRepo="${FRONTEND_REPO:-OpenListTeam/OpenList-Frontend}"
release_json=$(curl -fsSL --max-time 10 \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/$frontendRepo/releases/tags/beta-media")
tar_url=$(echo "$release_json" | jq -r '.assets[].browser_download_url' | grep "openlist-frontend-dist" | grep -v "lite" | grep "\.tar\.gz$")
echo "Downloading frontend from: $tar_url"
curl -fsSL "$tar_url" -o dist.tar.gz
rm -rf public/dist && mkdir -p public/dist
tar -zxvf dist.tar.gz -C public/dist
rm -rf dist.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Build
uses: OpenListTeam/cgo-actions@v1.2.2
with:
targets: ${{ matrix.target }}
musl-target-format: $os-$musl-$arch
github-token: ${{ secrets.GITHUB_TOKEN }}
out-dir: build
output: openlist-$target$ext
musl-base-url: "https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
x-flags: |
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@openlist.team>
github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit
github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling
- name: Compress
run: |
bash build.sh zip ${{ matrix.hash }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload assets to beta-media release
uses: softprops/action-gh-release@v2
with:
files: build/compress/*
prerelease: true
tag_name: beta-media
- name: Clean illegal characters from matrix.target
id: clean_target_name
run: |
ILLEGAL_CHARS_REGEX='[":<>|*?\\/\r\n]'
CLEANED_TARGET=$(echo "${{ matrix.target }}" | sed -E "s/$ILLEGAL_CHARS_REGEX//g")
echo "Original target: ${{ matrix.target }}"
echo "Cleaned target: $CLEANED_TARGET"
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
- name: Upload assets to github artifact
uses: actions/upload-artifact@v4
with:
name: beta-media builds for ${{ env.cleaned_target }}
path: ${{ github.workspace }}/build/compress/*
compression-level: 0
if-no-files-found: error
+20 -70
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -40,18 +40,15 @@ jobs:
npx changelogithub --output CHANGELOG.md
- name: Upload assets to beta release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh release view beta; then
gh release create beta --prerelease --notes-file CHANGELOG.md
else
gh release edit beta --prerelease --notes-file CHANGELOG.md
fi
gh release upload beta "CHANGELOG.md" --clobber
uses: softprops/action-gh-release@v2
with:
body_path: CHANGELOG.md
files: CHANGELOG.md
prerelease: true
tag_name: beta
- name: Upload assets to github artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: beta changelog
path: ${{ github.workspace }}/CHANGELOG.md
@@ -64,56 +61,31 @@ jobs:
strategy:
matrix:
include:
- target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch (exclude mips64le)
- target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch
hash: "md5"
flags: ""
goflags: ""
- target: "linux-(mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-compat-family
hash: "md5-linux-musl-mips"
flags: "-ldflags=-linkmode external -extldflags '-static -fpic'"
goflags: ""
musl_static: "true"
- target: "linux-!(arm*|mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-not-arm (exclude compat-family)
- target: "linux-!(arm*)-musl*" #musl-not-arm
hash: "md5-linux-musl"
flags: "-ldflags=-linkmode external -extldflags '-static -fpic'"
goflags: ""
musl_static: "true"
- target: "linux-arm*-musl*" #musl-arm
hash: "md5-linux-musl-arm"
flags: "-ldflags=-linkmode external -extldflags '-static -fpic'"
goflags: ""
musl_static: "true"
- target: "windows-arm64" #win-arm64
hash: "md5-windows-arm64"
flags: ""
goflags: ""
musl_static: "false"
- target: "windows7-*" #win7
hash: "md5-windows7"
flags: ""
goflags: "-tags=sqlite_cgo_compat"
musl_static: "false"
- target: "android-*" #android
hash: "md5-android"
flags: ""
goflags: ""
musl_static: "false"
- target: "freebsd-*" #freebsd
hash: "md5-freebsd"
flags: ""
goflags: ""
musl_static: "false"
name: Beta Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.25.0"
@@ -124,11 +96,9 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Build
uses: OpenListTeam/cgo-actions@v1.2.5
uses: OpenListTeam/cgo-actions@v1.2.2
with:
targets: ${{ matrix.target }}
flags: ${{ matrix.flags || '-ldflags=' }}
static-link-for-musl: true
musl-target-format: $os-$musl-$arch
github-token: ${{ secrets.GITHUB_TOKEN }}
out-dir: build
@@ -140,26 +110,6 @@ jobs:
github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit
github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling
env:
GOFLAGS: ${{ matrix.goflags }}
- name: Verify musl binaries are static
if: matrix.musl_static == 'true'
run: |
set -e
shopt -s nullglob
files=(build/openlist-*-musl-*)
if [ ${#files[@]} -eq 0 ]; then
echo "No musl binaries found"
exit 1
fi
for f in "${files[@]}"; do
if readelf -l "$f" | grep -q "Requesting program interpreter"; then
echo "Dynamic binary detected: $f"
readelf -l "$f" | grep "Requesting program interpreter" || true
exit 1
fi
done
- name: Compress
run: |
@@ -167,13 +117,13 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# See above
- name: Upload assets to beta release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for file in build/compress/*; do
gh release upload beta "$file" --clobber
done
uses: softprops/action-gh-release@v2
with:
files: build/compress/*
prerelease: true
tag_name: beta
- name: Clean illegal characters from matrix.target
id: clean_target_name
@@ -185,7 +135,7 @@ jobs:
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
- name: Upload assets to github artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: beta builds for ${{ env.cleaned_target }}
path: ${{ github.workspace }}/build/compress/*
+4 -16
View File
@@ -25,13 +25,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: benjlevesque/short-sha@v3.0
id: short-sha
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.25.0"
@@ -42,11 +42,9 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Build
uses: OpenListTeam/cgo-actions@v1.2.5
uses: OpenListTeam/cgo-actions@v1.2.2
with:
targets: ${{ matrix.target }}
flags: ${{ contains(matrix.target, '-musl') && '-ldflags=-linkmode external -extldflags ''-static -fpic''' || '-ldflags=' }}
static-link-for-musl: true
musl-target-format: $os-$musl-$arch
github-token: ${{ secrets.GITHUB_TOKEN }}
out-dir: build
@@ -58,18 +56,8 @@ jobs:
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling
output: openlist$ext
- name: Verify musl binary is static
if: contains(matrix.target, '-musl')
run: |
set -e
if readelf -l build/openlist | grep -q "Requesting program interpreter"; then
echo "Dynamic binary detected: build/openlist"
readelf -l build/openlist | grep "Requesting program interpreter" || true
exit 1
fi
- name: Upload artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: openlist_${{ steps.short-sha.outputs.sha }}_${{ matrix.target }}
path: build/*
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
-215
View File
@@ -1,215 +0,0 @@
name: Container Registry Cleanup
permissions:
contents: read
packages: write
on:
workflow_dispatch:
inputs:
package:
description: 'The name of the package to clean up'
required: true
default: 'openlist-git'
owner:
description: 'The owner of the package (user or organization)'
required: false
default: 'openlistteam'
older_than:
description: 'Delete untagged images older than this many days'
required: false
default: '-1'
untagged_timestamp_tolerance:
description: 'Tolerance in milliseconds for untagged images close to tagged ones'
required: false
# We do this because multi-arch docker containers will be pushed as separate untagged images
# to the container registry and GitHub cannot recognize them as part of a tagged image.
# Setting this option large enough prevents accidental deletion of one of these images.
# Here we set it to 10 seconds
default: '10000'
token:
description: 'GitHub token with permissions to delete packages (leave empty to use default GITHUB_TOKEN)'
required: false
default: ''
jobs:
cleanup:
runs-on: ubuntu-slim
steps:
- name: Check inputs and tools
id: setup
shell: bash
run: |
PACKAGE="${{ github.event.inputs.package || 'openlist-git' }}"
OLDER="${{ github.event.inputs.older_than || '-1' }}"
TOLERANCE_VAL="${{ github.event.inputs.untagged_timestamp_tolerance || '10000' }}"
TOKEN_INPUT="${{ github.event.inputs.token || '' }}"
OWNER="${{ github.event.inputs.owner || 'openlistteam' }}"
echo "package_name=$PACKAGE" >> "$GITHUB_OUTPUT"
echo "older_than=$OLDER" >> "$GITHUB_OUTPUT"
echo "tolerance=$TOLERANCE_VAL" >> "$GITHUB_OUTPUT"
echo "owner=$OWNER" >> "$GITHUB_OUTPUT"
if [[ -z "$TOKEN_INPUT" ]]; then
echo "::notice:: No token provided, using GITHUB_TOKEN instead."
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
else
echo "::add-mask::$TOKEN_INPUT"
echo "token=$TOKEN_INPUT" >> "$GITHUB_OUTPUT"
fi
if [[ -z "$PACKAGE" || -z "$OWNER" || ( -z "$TOKEN_INPUT" && -z "${{ secrets.GITHUB_TOKEN }}" ) ]]; then
echo "::error:: Missing required inputs (package, token, or owner context)."
exit 1
fi
command -v curl >/dev/null 2>&1 || { echo "::error::curl is required but not installed."; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "::error::jq is required but not installed."; exit 1; }
- name: Determine owner type (user or organization)
id: owner_type
shell: bash
run: |
OWNER="${{ steps.setup.outputs.owner }}"
TOKEN="${{ steps.setup.outputs.token }}"
TYPE=$(curl -s -H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/users/${OWNER}" | jq -r '.type')
if [[ "$TYPE" == "Organization" ]]; then
OWNER_PATH="orgs"
else
OWNER_PATH="users"
fi
echo "owner_path=$OWNER_PATH" >> "$GITHUB_OUTPUT"
echo "Detected owner type: $TYPE (using /${OWNER_PATH}/)"
- name: Access all versions from GitHub
id: fetch
shell: bash
run: |
PACKAGE_NAME="${{ steps.setup.outputs.package_name }}"
OWNER="${{ steps.setup.outputs.owner }}"
TOKEN="${{ steps.setup.outputs.token }}"
OWNER_PATH="${{ steps.owner_type.outputs.owner_path }}"
BASE_URL="https://api.github.com/${OWNER_PATH}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
URL="${BASE_URL}?per_page=100"
TEMP_FILE="/tmp/all_versions.ndjson"
> "$TEMP_FILE"
PAGE=1
while [[ -n "$URL" && $PAGE -le 10 ]]; do
echo "Fetching: $URL"
curl -s -H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-D headers.txt \
"$URL" > page.json
jq -c '.[]' page.json >> "$TEMP_FILE"
URL=$(grep -i '^link:' headers.txt | grep -o '<[^>]*>;\s*rel="next"' | sed 's/<\(.*\)>.*/\1/' || true)
PAGE=$((PAGE + 1))
done
rm -f headers.txt page.json
echo "temp_file=$TEMP_FILE" >> "$GITHUB_OUTPUT"
- name: Filter untagged versions
id: filter
shell: bash
run: |
OLDER_THAN="${{ steps.setup.outputs.older_than }}"
TOLERANCE="${{ steps.setup.outputs.tolerance }}"
TEMP_FILE="${{ steps.fetch.outputs.temp_file }}"
TO_DELETE=$(jq -c -s \
--arg older_than "$OLDER_THAN" \
--arg tolerance "$TOLERANCE" \
'
def abs: if . < 0 then -. else . end;
def to_ms: sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime * 1000;
. as $all |
($older_than | tonumber) as $older_than_val |
($tolerance | tonumber) as $tolerance_val |
now as $now |
(if $older_than_val > 0 then ($now - ($older_than_val * 86400)) * 1000 else 0 end) as $cutoff_ms |
[ $all[] | select((.metadata.container.tags // []) | length > 0) | .created_at | to_ms ] as $tagged_times |
$all[] |
select((.metadata.container.tags // []) | length == 0) |
. as $item |
(.created_at | to_ms) as $created_ms |
(if $older_than_val > 0 then $created_ms < $cutoff_ms else true end) as $pass_age |
(if $tolerance_val <= 0 then true
else [ $tagged_times[] | select(($created_ms - .) | abs < $tolerance_val) ] | length == 0
end) as $pass_tolerance |
select($pass_age and $pass_tolerance) |
{id, name}
' "$TEMP_FILE")
rm -f "$TEMP_FILE"
if [[ -z "$TO_DELETE" ]]; then
DELETE_COUNT=0
else
DELETE_COUNT=$(echo "$TO_DELETE" | wc -l | tr -d ' ')
fi
echo "Found $DELETE_COUNT untagged images no longer necessary"
{
echo 'to_delete<<EOF'
echo "$TO_DELETE"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
echo "delete_count=$DELETE_COUNT" >> "$GITHUB_OUTPUT"
- name: Delete untagged versions
id: delete
shell: bash
run: |
PACKAGE_NAME="${{ steps.setup.outputs.package_name }}"
OWNER="${{ steps.setup.outputs.owner }}"
TOKEN="${{ steps.setup.outputs.token }}"
OWNER_PATH="${{ steps.owner_type.outputs.owner_path }}"
DELETE_COUNT="${{ steps.filter.outputs.delete_count }}"
TO_DELETE='${{ steps.filter.outputs.to_delete }}'
if [[ "$DELETE_COUNT" -gt 0 && -n "$TO_DELETE" ]]; then
while IFS= read -r item; do
[[ -z "$item" ]] && continue
# $item 是完整的单行 JSON,如 {"id":123,"name":"sha256:..."}
ID=$(echo "$item" | jq -r '.id')
NAME=$(echo "$item" | jq -r '.name')
DELETE_URL="https://api.github.com/${OWNER_PATH}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/${ID}"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"$DELETE_URL")
if [[ "$HTTP_CODE" == "204" ]]; then
echo "Deleted untagged container image '${NAME}'"
else
echo "::warning:: Failed to delete ${NAME} (ID: ${ID}). HTTP Status: ${HTTP_CODE}"
fi
done <<< "$TO_DELETE"
else
echo "No images to delete."
fi
echo "Cleanup completed."
+10 -50
View File
@@ -19,49 +19,11 @@ jobs:
uses: actions/github-script@v7
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";
const unchecked = /- \[ \] /.test(issueBody);
let comment = "感谢您联系OpenList。我们会尽快回复您。\n";
comment += "Thanks for contacting OpenList. We will reply to you as soon as possible.\n\n";
if (unchecked) {
comment += "由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。\n";
comment += "如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!\n";
comment += "Since your issue contains some unchecked tasks, it may be closed after manual review.\n";
@@ -69,12 +31,12 @@ jobs:
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
});
}
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
pr-title-check:
runs-on: ubuntu-latest
@@ -85,14 +47,12 @@ jobs:
with:
script: |
const title = context.payload.pull_request.title || "";
const ok = /^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(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,
+8 -8
View File
@@ -44,12 +44,12 @@ jobs:
swap-storage: true
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: '1.25.0'
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -69,9 +69,9 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for file in build/compress/*; do
gh release upload "${{ github.event.release.tag_name }}" "$file" --clobber
done
uses: softprops/action-gh-release@v2
with:
files: build/compress/*
prerelease: false
tag_name: ${{ github.event.release.tag_name }}
+8 -8
View File
@@ -43,9 +43,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: actions/setup-go@v6
- uses: actions/setup-go@v5
with:
go-version: '1.25.0'
@@ -69,7 +69,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
overwrite: true
@@ -83,9 +83,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: actions/setup-go@v6
- uses: actions/setup-go@v5
with:
go-version: '1.25.0'
@@ -109,7 +109,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME_LITE }}
overwrite: true
@@ -146,7 +146,7 @@ jobs:
tag_favor: "suffix=-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
@@ -230,7 +230,7 @@ jobs:
tag_favor: "suffix=-lite-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME_LITE }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: Sync GitHub to Gitee
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
+4 -4
View File
@@ -32,9 +32,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: actions/setup-go@v6
- uses: actions/setup-go@v5
with:
go-version: '1.25.0'
@@ -58,7 +58,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
overwrite: true
@@ -97,7 +97,7 @@ jobs:
tag_favor: "suffix=-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
-60
View File
@@ -1,60 +0,0 @@
# Agent Instructions
## Issues
Before creating an issue, review the available issue templates in the `.github` directory.
When drafting the issue:
- Use the most appropriate template.
- Follow the template structure.
- Fill in all required sections.
- Remove sections that the template explicitly marks as optional or not applicable.
- Do not invent reproduction steps, logs, screenshots, or expected behavior.
## Pull Requests
Before creating a pull request, read `.github/PULL_REQUEST_TEMPLATE.md`.
When drafting the pull request:
- Follow the template structure.
- Use the title format required by the template.
- Fill in or remove each section according to the template guidance.
- Include testing details, or explain why testing was not run.
- Do not invent testing results.
- Do not claim validation, verification, or review steps that were not actually performed.
## Automated Contributions
Fully automated contributions are not considered equivalent to normal community participation.
A contribution may be considered fully automated if it is submitted through an automated agent, or if the submitting account participates in project discussions through an automated agent, without meaningful human review or intervention.
When making this determination, maintainers may consider the overall behavior of the account, including but not limited to disclosed agent usage, interaction patterns, response characteristics, and other available evidence. No single factor is determinative.
Maintainers reserve the right to accept, reject, modify, or reimplement any contribution independently of any action taken against the submitting account. Acceptance of a contribution does not imply acceptance of the submitting account or its contribution method. If an account is determined to be primarily operated through automated processes, we may need to restrict its future participation in contributions until that determination is rescinded.
## Git Commits
When creating commits, follow the repository `git-commit` skill rules:
- Use Conventional Commits title format: `type(scope): subject`.
- Allowed types: `feat`, `fix`, `refactor`, `perf`, `docs`, `style`, `test`, `build`, `ci`, `chore`, `revert`.
- Use a meaningful scope based on the main module, package, or feature.
- Write the subject in imperative mood and describe the actual change.
- Use a concise Markdown list in the commit body, with each item describing one key change.
- Do not invent changes that are not present in the diff.
- Do not describe behavior, refactors, fixes, or tests that are not reflected in the commit.
Include at most one `Co-authored-by` trailer that matches the AI assistant actually used to produce the change.
Examples:
- `Co-authored-by: Codex <267193182+codex@users.noreply.github.com>`
- `Co-authored-by: GitHub Copilot <copilot@github.com>`
- `Co-authored-by: Claude <81847+claude@users.noreply.github.com>`
If you are not one of the listed assistants, do not add a `Co-authored-by` trailer.
Instead, ask the human collaborator to provide the exact `Co-authored-by` trailer to use. Do not invent, infer, or generate one yourself.
+4 -4
View File
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
@@ -116,7 +116,7 @@ the community.
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
@@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+7 -40
View File
@@ -7,7 +7,7 @@
Prerequisites:
- [git](https://git-scm.com)
- [Go](https://golang.org/doc/install) version declared in [`go.mod`](./go.mod)
- [Go 1.24+](https://golang.org/doc/install)
- [gcc](https://gcc.gnu.org/)
- [nodejs](https://nodejs.org/)
@@ -16,8 +16,8 @@ Prerequisites:
Fork and clone `OpenList` and `OpenList-Frontend` anywhere:
```shell
git clone https://github.com/<your-username>/OpenList.git
git clone --recurse-submodules https://github.com/<your-username>/OpenList-Frontend.git
$ git clone https://github.com/<your-username>/OpenList.git
$ git clone --recurse-submodules https://github.com/<your-username>/OpenList-Frontend.git
```
## Creating a branch
@@ -25,7 +25,7 @@ git clone --recurse-submodules https://github.com/<your-username>/OpenList-Front
Create a new branch from the `main` branch, with an appropriate name.
```shell
git checkout -b <branch-name>
$ git checkout -b <branch-name>
```
## Preview your change
@@ -33,36 +33,26 @@ git checkout -b <branch-name>
### backend
```shell
go run main.go
$ go run main.go
```
### frontend
```shell
pnpm dev
$ pnpm dev
```
## Add a new driver
Copy `drivers/template` folder and rename it, and follow the comments in it.
## Community and policies
By contributing, you agree to follow the repository's code of conduct and license terms.
- Code of conduct: [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
- License: [LICENSE](./LICENSE)
- Security issues: please report privately according to [SECURITY.md](./SECURITY.md)
If your contribution includes substantial AI-assisted content, disclose the tools used and the scope of assistance in the pull request.
## Create a commit
Commit messages should be well formatted, and to make that "standardized".
Submit your pull request. For PR titles, follow [Conventional Commits](https://www.conventionalcommits.org).
<https://github.com/OpenListTeam/OpenList/issues/376>
https://github.com/OpenListTeam/OpenList/issues/376
It's suggested to sign your commits. See: [How to sign commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits)
@@ -82,29 +72,6 @@ At least 1 approving review is required by reviewers with write access. You can
(Optional) After your pull request is merged, you can delete your branch.
## AI Disclosure
If your pull request includes substantial AI-assisted content, disclose it in the PR description.
The pull request description must follow the repository's pull request template.
Fully automated contributions are not considered equivalent to normal community participation.
A contribution may be considered fully automated if it is submitted through an automated agent, or if the submitting account participates in project discussions through an automated agent, without meaningful human review or intervention.
When making this determination, maintainers may consider the overall behavior of the account, including but not limited to disclosed agent usage, interaction patterns, response characteristics, and other available evidence. No single factor is determinative.
Maintainers reserve the right to accept, reject, modify, or reimplement any contribution independently of any action taken against the submitting account. Acceptance of a contribution does not imply acceptance of the submitting account or its contribution method. If an account is determined to be primarily operated through automated processes, we may need to restrict its future participation in contributions until that determination is rescinded.
Please include:
- Tools used, such as ChatGPT, GitHub Copilot, Claude, Cursor, or other AI tools.
- Usage scope, such as code generation, refactoring, documentation, tests, translation, or review assistance.
- Confirmation that you have reviewed and validated all AI-assisted content before submission.
- Confirmation that the submitted content complies with this repository's license and contribution policies.
Minor AI assistance, such as typo fixes, autocomplete, formatting suggestions, or wording polish, does not need to be disclosed.
---
Thank you for your contribution! Let's make OpenList better together!
+6 -22
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
<p><em>OpenList is a resilient, long-term governance, community-driven fork of AList — built to defend open source against trust-based attacks.</em></p>
@@ -14,7 +14,7 @@
---
- English | [中文](./README/README_cn.md) | [日本語](./README/README_ja.md) | [Dutch](./README/README_nl.md) | [한국어](./README/README_ko.md) | [Deutsch](./README/README_de.md) | [Русский](./README/README_ru.md) | [Français](./README/README_fr.md) | [Español](./README/README_es.md) | [العربية](./README/README_ar.md)
- English | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md)
- [Contributing](./CONTRIBUTING.md)
- [CODE OF CONDUCT](./CODE_OF_CONDUCT.md)
@@ -95,7 +95,6 @@ Thank you for your support and understanding of the OpenList project.
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk Docs](https://alidocs.dingtalk.com/)
- [x] Easy to deploy and out-of-the-box
- [x] File preview (PDF, markdown, code, plain text, ...)
- [x] Image preview in gallery mode
@@ -117,32 +116,18 @@ Thank you for your support and understanding of the OpenList project.
## Document
- 📘 [Docs](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Terms of Use](https://doc.oplist.org/terms)
- 🔒 [Privacy Policy](https://doc.oplist.org/privacy)
- 📘 [Global Site](https://doc.oplist.org)
- 📚 [Backup Site](https://doc.openlist.team)
- 🌏 [CN Site](https://doc.oplist.org.cn)
## Demo
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
N/A (to be rebuilt)
## Discussion
Please refer to [*Discussions*](https://github.com/OpenListTeam/OpenList/discussions) for raising general questions, ***Issues* is for bug reports and feature requests only.**
## Sponsor
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## Donors
Thanks to the following donors for their generous support:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## License
The `OpenList` is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license.
@@ -172,4 +157,3 @@ We sincerely thank the author [Xhofe](https://github.com/Xhofe) of the original
Thanks goes to these wonderful people:
[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors)
-136
View File
@@ -1,136 +0,0 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<p><em>OpenList هو تفرّع من AList يتميز بالمرونة والحوكمة طويلة الأمد ويقوده المجتمع، وقد بُني للدفاع عن البرمجيات مفتوحة المصدر ضد الهجمات القائمة على الثقة.</em></p>
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
</div>
---
- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | العربية
- [Contributing](../CONTRIBUTING.md)
- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md)
- [LICENSE](../LICENSE)
## إخلاء المسؤولية
OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency.
OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names.
- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub.
- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments.
- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license.
- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it.
## الميزات
- [x] Multiple storages
- [x] Local storage
- [x] [Aliyundrive](https://www.alipan.com)
- [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US)
- [x] [189cloud](https://cloud.189.cn) (Personal, Family)
- [x] [GoogleDrive](https://drive.google.com)
- [x] [123pan](https://www.123pan.com)
- [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol)
- [x] [PikPak](https://www.mypikpak.com)
- [x] [S3](https://aws.amazon.com/s3)
- [x] [Seafile](https://seafile.com)
- [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage)
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
- [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com))
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
- [x] [UC](https://drive.uc.cn)
- [x] [Quark](https://pan.quark.cn)
- [x] [Thunder](https://pan.xunlei.com)
- [x] [Lanzou](https://www.lanzou.com)
- [x] [ILanzou](https://www.ilanzou.com)
- [x] [Google photo](https://photos.google.com)
- [x] [Mega.nz](https://mega.nz)
- [x] [Baidu photo](https://photo.baidu.com)
- [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block)
- [x] [115](https://115.com)
- [x] [Cloudreve](https://cloudreve.org)
- [x] [Dropbox](https://www.dropbox.com)
- [x] [FeijiPan](https://www.feijipan.com)
- [x] [dogecloud](https://www.dogecloud.com/product/oss)
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
- [x] [Chaoxing](https://www.chaoxing.com)
- [x] [CNB](https://cnb.cool/)
- [x] [Degoo](https://degoo.com)
- [x] [Doubao](https://www.doubao.com)
- [x] [Febbox](https://www.febbox.com)
- [x] [GitHub](https://github.com)
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk Docs](https://alidocs.dingtalk.com/)
- [x] Easy to deploy and out-of-the-box
- [x] File preview (PDF, markdown, code, plain text, ...)
- [x] Image preview in gallery mode
- [x] Video and audio preview, with lyrics and subtitles support
- [x] Office document preview (docx, pptx, xlsx, ...)
- [x] `README.md` preview rendering
- [x] File permalink copy and direct file download
- [x] Dark mode and i18n
- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy
- [x] File/folder package download and web upload operations
- [x] Offline download, cross-storage copy, and multi-thread download acceleration
## الوثائق
- 📘 [Docs](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Terms of Use](https://doc.oplist.org/terms)
- 🔒 [Privacy Policy](https://doc.oplist.org/privacy)
## العرض التجريبي
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
## النقاش
Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only.
## الراعي
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## المتبرعون
Thanks to the following donors for their generous support:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## الترخيص
OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license.
## تواصل معنا
- [@GitHub](https://github.com/OpenListTeam)
- [Telegram Group](https://t.me/OpenListTeam)
- [Telegram Channel](https://t.me/OpenListOfficial)
## المساهمون
We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors.
[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors)
-136
View File
@@ -1,136 +0,0 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<p><em>OpenList ist ein widerstandsfähiger, langfristig verwalteter und Community-getriebener Fork von AList, entwickelt zum Schutz von Open Source vor vertrauensbasierten Angriffen.</em></p>
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
</div>
---
- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | Deutsch | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md)
- [Contributing](../CONTRIBUTING.md)
- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md)
- [LICENSE](../LICENSE)
## Haftungsausschluss
OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency.
OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names.
- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub.
- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments.
- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license.
- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it.
## Funktionen
- [x] Multiple storages
- [x] Local storage
- [x] [Aliyundrive](https://www.alipan.com)
- [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US)
- [x] [189cloud](https://cloud.189.cn) (Personal, Family)
- [x] [GoogleDrive](https://drive.google.com)
- [x] [123pan](https://www.123pan.com)
- [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol)
- [x] [PikPak](https://www.mypikpak.com)
- [x] [S3](https://aws.amazon.com/s3)
- [x] [Seafile](https://seafile.com)
- [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage)
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
- [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com))
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
- [x] [UC](https://drive.uc.cn)
- [x] [Quark](https://pan.quark.cn)
- [x] [Thunder](https://pan.xunlei.com)
- [x] [Lanzou](https://www.lanzou.com)
- [x] [ILanzou](https://www.ilanzou.com)
- [x] [Google photo](https://photos.google.com)
- [x] [Mega.nz](https://mega.nz)
- [x] [Baidu photo](https://photo.baidu.com)
- [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block)
- [x] [115](https://115.com)
- [x] [Cloudreve](https://cloudreve.org)
- [x] [Dropbox](https://www.dropbox.com)
- [x] [FeijiPan](https://www.feijipan.com)
- [x] [dogecloud](https://www.dogecloud.com/product/oss)
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
- [x] [Chaoxing](https://www.chaoxing.com)
- [x] [CNB](https://cnb.cool/)
- [x] [Degoo](https://degoo.com)
- [x] [Doubao](https://www.doubao.com)
- [x] [Febbox](https://www.febbox.com)
- [x] [GitHub](https://github.com)
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk Docs](https://alidocs.dingtalk.com/)
- [x] Easy to deploy and out-of-the-box
- [x] File preview (PDF, markdown, code, plain text, ...)
- [x] Image preview in gallery mode
- [x] Video and audio preview, with lyrics and subtitles support
- [x] Office document preview (docx, pptx, xlsx, ...)
- [x] `README.md` preview rendering
- [x] File permalink copy and direct file download
- [x] Dark mode and i18n
- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy
- [x] File/folder package download and web upload operations
- [x] Offline download, cross-storage copy, and multi-thread download acceleration
## Dokumentation
- 📘 [Docs](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Terms of Use](https://doc.oplist.org/terms)
- 🔒 [Privacy Policy](https://doc.oplist.org/privacy)
## Demo
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
## Diskussion
Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only.
## Sponsor
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## Spender
Thanks to the following donors for their generous support:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## Lizenz
OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license.
## Kontakt
- [@GitHub](https://github.com/OpenListTeam)
- [Telegram Group](https://t.me/OpenListTeam)
- [Telegram Channel](https://t.me/OpenListOfficial)
## Mitwirkende
We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors.
[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors)
-136
View File
@@ -1,136 +0,0 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<p><em>OpenList es un fork de AList resiliente, con gobernanza a largo plazo e impulsado por la comunidad, creado para defender el código abierto frente a ataques basados en la confianza.</em></p>
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
</div>
---
- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | Español | [العربية](./README_ar.md)
- [Contributing](../CONTRIBUTING.md)
- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md)
- [LICENSE](../LICENSE)
## Aviso legal
OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency.
OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names.
- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub.
- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments.
- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license.
- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it.
## Características
- [x] Multiple storages
- [x] Local storage
- [x] [Aliyundrive](https://www.alipan.com)
- [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US)
- [x] [189cloud](https://cloud.189.cn) (Personal, Family)
- [x] [GoogleDrive](https://drive.google.com)
- [x] [123pan](https://www.123pan.com)
- [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol)
- [x] [PikPak](https://www.mypikpak.com)
- [x] [S3](https://aws.amazon.com/s3)
- [x] [Seafile](https://seafile.com)
- [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage)
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
- [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com))
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
- [x] [UC](https://drive.uc.cn)
- [x] [Quark](https://pan.quark.cn)
- [x] [Thunder](https://pan.xunlei.com)
- [x] [Lanzou](https://www.lanzou.com)
- [x] [ILanzou](https://www.ilanzou.com)
- [x] [Google photo](https://photos.google.com)
- [x] [Mega.nz](https://mega.nz)
- [x] [Baidu photo](https://photo.baidu.com)
- [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block)
- [x] [115](https://115.com)
- [x] [Cloudreve](https://cloudreve.org)
- [x] [Dropbox](https://www.dropbox.com)
- [x] [FeijiPan](https://www.feijipan.com)
- [x] [dogecloud](https://www.dogecloud.com/product/oss)
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
- [x] [Chaoxing](https://www.chaoxing.com)
- [x] [CNB](https://cnb.cool/)
- [x] [Degoo](https://degoo.com)
- [x] [Doubao](https://www.doubao.com)
- [x] [Febbox](https://www.febbox.com)
- [x] [GitHub](https://github.com)
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk Docs](https://alidocs.dingtalk.com/)
- [x] Easy to deploy and out-of-the-box
- [x] File preview (PDF, markdown, code, plain text, ...)
- [x] Image preview in gallery mode
- [x] Video and audio preview, with lyrics and subtitles support
- [x] Office document preview (docx, pptx, xlsx, ...)
- [x] `README.md` preview rendering
- [x] File permalink copy and direct file download
- [x] Dark mode and i18n
- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy
- [x] File/folder package download and web upload operations
- [x] Offline download, cross-storage copy, and multi-thread download acceleration
## Documentación
- 📘 [Docs](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Terms of Use](https://doc.oplist.org/terms)
- 🔒 [Privacy Policy](https://doc.oplist.org/privacy)
## Demo
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
## Discusión
Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only.
## Patrocinador
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## Donantes
Thanks to the following donors for their generous support:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## Licencia
OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license.
## Contacto
- [@GitHub](https://github.com/OpenListTeam)
- [Telegram Group](https://t.me/OpenListTeam)
- [Telegram Channel](https://t.me/OpenListOfficial)
## Colaboradores
We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors.
[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors)
-136
View File
@@ -1,136 +0,0 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<p><em>OpenList est un fork d’AList résilient, gouverné sur le long terme et porté par la communauté, conçu pour protéger l’open source contre les attaques fondées sur la confiance.</em></p>
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
</div>
---
- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | Français | [Español](./README_es.md) | [العربية](./README_ar.md)
- [Contributing](../CONTRIBUTING.md)
- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md)
- [LICENSE](../LICENSE)
## Avertissement
OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency.
OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names.
- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub.
- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments.
- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license.
- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it.
## Fonctionnalités
- [x] Multiple storages
- [x] Local storage
- [x] [Aliyundrive](https://www.alipan.com)
- [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US)
- [x] [189cloud](https://cloud.189.cn) (Personal, Family)
- [x] [GoogleDrive](https://drive.google.com)
- [x] [123pan](https://www.123pan.com)
- [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol)
- [x] [PikPak](https://www.mypikpak.com)
- [x] [S3](https://aws.amazon.com/s3)
- [x] [Seafile](https://seafile.com)
- [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage)
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
- [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com))
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
- [x] [UC](https://drive.uc.cn)
- [x] [Quark](https://pan.quark.cn)
- [x] [Thunder](https://pan.xunlei.com)
- [x] [Lanzou](https://www.lanzou.com)
- [x] [ILanzou](https://www.ilanzou.com)
- [x] [Google photo](https://photos.google.com)
- [x] [Mega.nz](https://mega.nz)
- [x] [Baidu photo](https://photo.baidu.com)
- [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block)
- [x] [115](https://115.com)
- [x] [Cloudreve](https://cloudreve.org)
- [x] [Dropbox](https://www.dropbox.com)
- [x] [FeijiPan](https://www.feijipan.com)
- [x] [dogecloud](https://www.dogecloud.com/product/oss)
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
- [x] [Chaoxing](https://www.chaoxing.com)
- [x] [CNB](https://cnb.cool/)
- [x] [Degoo](https://degoo.com)
- [x] [Doubao](https://www.doubao.com)
- [x] [Febbox](https://www.febbox.com)
- [x] [GitHub](https://github.com)
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk Docs](https://alidocs.dingtalk.com/)
- [x] Easy to deploy and out-of-the-box
- [x] File preview (PDF, markdown, code, plain text, ...)
- [x] Image preview in gallery mode
- [x] Video and audio preview, with lyrics and subtitles support
- [x] Office document preview (docx, pptx, xlsx, ...)
- [x] `README.md` preview rendering
- [x] File permalink copy and direct file download
- [x] Dark mode and i18n
- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy
- [x] File/folder package download and web upload operations
- [x] Offline download, cross-storage copy, and multi-thread download acceleration
## Documentation
- 📘 [Docs](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Terms of Use](https://doc.oplist.org/terms)
- 🔒 [Privacy Policy](https://doc.oplist.org/privacy)
## Démo
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
## Discussion
Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only.
## Sponsor
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## Donateurs
Thanks to the following donors for their generous support:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## Licence
OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license.
## Nous contacter
- [@GitHub](https://github.com/OpenListTeam)
- [Telegram Group](https://t.me/OpenListTeam)
- [Telegram Channel](https://t.me/OpenListOfficial)
## Contributeurs
We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors.
[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors)
-136
View File
@@ -1,136 +0,0 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<p><em>OpenList는 신뢰 기반 공격으로부터 오픈 소스를 보호하기 위해 만들어진, 탄력적이고 장기 거버넌스를 지향하는 커뮤니티 주도 AList 포크입니다.</em></p>
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
</div>
---
- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | 한국어 | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md)
- [Contributing](../CONTRIBUTING.md)
- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md)
- [LICENSE](../LICENSE)
## 면책 조항
OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency.
OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names.
- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub.
- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments.
- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license.
- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it.
## 기능
- [x] Multiple storages
- [x] Local storage
- [x] [Aliyundrive](https://www.alipan.com)
- [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US)
- [x] [189cloud](https://cloud.189.cn) (Personal, Family)
- [x] [GoogleDrive](https://drive.google.com)
- [x] [123pan](https://www.123pan.com)
- [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol)
- [x] [PikPak](https://www.mypikpak.com)
- [x] [S3](https://aws.amazon.com/s3)
- [x] [Seafile](https://seafile.com)
- [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage)
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
- [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com))
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
- [x] [UC](https://drive.uc.cn)
- [x] [Quark](https://pan.quark.cn)
- [x] [Thunder](https://pan.xunlei.com)
- [x] [Lanzou](https://www.lanzou.com)
- [x] [ILanzou](https://www.ilanzou.com)
- [x] [Google photo](https://photos.google.com)
- [x] [Mega.nz](https://mega.nz)
- [x] [Baidu photo](https://photo.baidu.com)
- [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block)
- [x] [115](https://115.com)
- [x] [Cloudreve](https://cloudreve.org)
- [x] [Dropbox](https://www.dropbox.com)
- [x] [FeijiPan](https://www.feijipan.com)
- [x] [dogecloud](https://www.dogecloud.com/product/oss)
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
- [x] [Chaoxing](https://www.chaoxing.com)
- [x] [CNB](https://cnb.cool/)
- [x] [Degoo](https://degoo.com)
- [x] [Doubao](https://www.doubao.com)
- [x] [Febbox](https://www.febbox.com)
- [x] [GitHub](https://github.com)
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk Docs](https://alidocs.dingtalk.com/)
- [x] Easy to deploy and out-of-the-box
- [x] File preview (PDF, markdown, code, plain text, ...)
- [x] Image preview in gallery mode
- [x] Video and audio preview, with lyrics and subtitles support
- [x] Office document preview (docx, pptx, xlsx, ...)
- [x] `README.md` preview rendering
- [x] File permalink copy and direct file download
- [x] Dark mode and i18n
- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy
- [x] File/folder package download and web upload operations
- [x] Offline download, cross-storage copy, and multi-thread download acceleration
## 문서
- 📘 [Docs](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Terms of Use](https://doc.oplist.org/terms)
- 🔒 [Privacy Policy](https://doc.oplist.org/privacy)
## 데모
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
## 토론
Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only.
## 후원
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## 후원자
Thanks to the following donors for their generous support:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## 라이선스
OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license.
## 문의
- [@GitHub](https://github.com/OpenListTeam)
- [Telegram Group](https://t.me/OpenListTeam)
- [Telegram Channel](https://t.me/OpenListOfficial)
## 기여자
We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors.
[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors)
-136
View File
@@ -1,136 +0,0 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<p><em>OpenList — устойчивый, управляемый сообществом форк AList с долгосрочным управлением, созданный для защиты open source от атак, основанных на доверии.</em></p>
<img src="https://goreportcard.com/badge/github.com/OpenListTeam/OpenList/v3" alt="latest version" />
<a href="https://github.com/OpenListTeam/OpenList/blob/main/LICENSE"><img src="https://img.shields.io/github/license/OpenListTeam/OpenList" alt="License" /></a>
<a href="https://github.com/OpenListTeam/OpenList/actions?query=workflow%3ABuild"><img src="https://img.shields.io/github/actions/workflow/status/OpenListTeam/OpenList/build.yml?branch=main" alt="Build status" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/release/OpenListTeam/OpenList" alt="latest version" /></a>
<a href="https://github.com/OpenListTeam/OpenList/discussions"><img src="https://img.shields.io/github/discussions/OpenListTeam/OpenList?color=%23ED8936" alt="discussions" /></a>
<a href="https://github.com/OpenListTeam/OpenList/releases"><img src="https://img.shields.io/github/downloads/OpenListTeam/OpenList/total?color=%239F7AEA&logo=github" alt="Downloads" /></a>
</div>
---
- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | Русский | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md)
- [Contributing](../CONTRIBUTING.md)
- [CODE OF CONDUCT](../CODE_OF_CONDUCT.md)
- [LICENSE](../LICENSE)
## Отказ от ответственности
OpenList is an open-source project independently maintained by the OpenList Team under the AGPL-3.0 license, with a commitment to code openness and modification transparency.
OpenList has no official association with third-party derivative projects that use similar names, including OpenListApp/OpenListApp or paid proprietary software using the same or similar names.
- All software, code, and services of this project are maintained by the OpenList Team and are freely available on GitHub.
- Project documentation and API services primarily rely on charitable resources provided by Cloudflare. There are currently no paid plans or commercial deployments.
- Downstream projects should not use the "OpenList" name for impersonation or commercial confusion, and must not distribute OpenList-based code as closed source in violation of the AGPL license.
- This software is provided "as is" without warranties. Please comply with applicable laws and regulations when using it.
## Возможности
- [x] Multiple storages
- [x] Local storage
- [x] [Aliyundrive](https://www.alipan.com)
- [x] OneDrive / Sharepoint ([Global](https://www.microsoft.com/en-us/microsoft-365/onedrive/online-cloud-storage), [CN](https://portal.partner.microsoftonline.cn), DE, US)
- [x] [189cloud](https://cloud.189.cn) (Personal, Family)
- [x] [GoogleDrive](https://drive.google.com)
- [x] [123pan](https://www.123pan.com)
- [x] [FTP / SFTP](https://en.wikipedia.org/wiki/File_Transfer_Protocol)
- [x] [PikPak](https://www.mypikpak.com)
- [x] [S3](https://aws.amazon.com/s3)
- [x] [Seafile](https://seafile.com)
- [x] [UPYUN Storage Service](https://www.upyun.com/products/file-storage)
- [x] [WebDAV](https://en.wikipedia.org/wiki/WebDAV)
- [x] Teambition([China](https://www.teambition.com), [International](https://us.teambition.com))
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
- [x] [UC](https://drive.uc.cn)
- [x] [Quark](https://pan.quark.cn)
- [x] [Thunder](https://pan.xunlei.com)
- [x] [Lanzou](https://www.lanzou.com)
- [x] [ILanzou](https://www.ilanzou.com)
- [x] [Google photo](https://photos.google.com)
- [x] [Mega.nz](https://mega.nz)
- [x] [Baidu photo](https://photo.baidu.com)
- [x] [SMB](https://en.wikipedia.org/wiki/Server_Message_Block)
- [x] [115](https://115.com)
- [x] [Cloudreve](https://cloudreve.org)
- [x] [Dropbox](https://www.dropbox.com)
- [x] [FeijiPan](https://www.feijipan.com)
- [x] [dogecloud](https://www.dogecloud.com/product/oss)
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
- [x] [Chaoxing](https://www.chaoxing.com)
- [x] [CNB](https://cnb.cool/)
- [x] [Degoo](https://degoo.com)
- [x] [Doubao](https://www.doubao.com)
- [x] [Febbox](https://www.febbox.com)
- [x] [GitHub](https://github.com)
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk Docs](https://alidocs.dingtalk.com/)
- [x] Easy to deploy and out-of-the-box
- [x] File preview (PDF, markdown, code, plain text, ...)
- [x] Image preview in gallery mode
- [x] Video and audio preview, with lyrics and subtitles support
- [x] Office document preview (docx, pptx, xlsx, ...)
- [x] `README.md` preview rendering
- [x] File permalink copy and direct file download
- [x] Dark mode and i18n
- [x] Protected routes, WebDAV, Docker deploy, and Cloudflare Workers proxy
- [x] File/folder package download and web upload operations
- [x] Offline download, cross-storage copy, and multi-thread download acceleration
## Документация
- 📘 [Docs](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Terms of Use](https://doc.oplist.org/terms)
- 🔒 [Privacy Policy](https://doc.oplist.org/privacy)
## Демо
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
## Обсуждение
Please refer to [Discussions](https://github.com/OpenListTeam/OpenList/discussions) for general questions; Issues are for bug reports and feature requests only.
## Спонсор
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## Доноры
Thanks to the following donors for their generous support:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## Лицензия
OpenList is open-source software licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) license.
## Связаться с нами
- [@GitHub](https://github.com/OpenListTeam)
- [Telegram Group](https://t.me/OpenListTeam)
- [Telegram Channel](https://t.me/OpenListOfficial)
## Участники
We sincerely thank [Xhofe](https://github.com/Xhofe), author of the original project [AlistGo/alist](https://github.com/AlistGo/alist), and all other contributors.
[![Contributors](https://contrib.rocks/image?repo=OpenListTeam/OpenList)](https://github.com/OpenListTeam/OpenList/graphs/contributors)
+11 -26
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
<p><em>OpenList 是一个有韧性、长期治理、社区驱动的 AList 分支,旨在防御基于信任的开源攻击。</em></p>
@@ -14,11 +14,11 @@
---
- [English](../README.md) | 中文 | [日本語](./README_ja.md) | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md)
- [English](./README.md) | 中文 | [日本語](./README_ja.md) | [Dutch](./README_nl.md)
- [贡献指南](../CONTRIBUTING.md)
- [行为准则](../CODE_OF_CONDUCT.md)
- [许可证](../LICENSE)
- [贡献指南](./CONTRIBUTING.md)
- [行为准则](./CODE_OF_CONDUCT.md)
- [许可证](./LICENSE)
## 免责声明
@@ -95,7 +95,6 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [微云](https://www.weiyun.com)
- [x] [钉钉文档](https://alidocs.dingtalk.com/)
- [x] 部署方便,开箱即用
- [x] 文件预览(PDF、markdown、代码、纯文本等)
- [x] 画廊模式下的图片预览
@@ -117,32 +116,18 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3
## 文档
- 📘 [文档](https://doc.oplist.org)
- 🌏 [中国镜像](https://doc.oplist.org.cn)
- ⚖️ [使用条款](https://doc.oplist.org/terms)
- 🔒 [隐私政策](https://doc.oplist.org/privacy)
- 🌏 [国内站点](https://doc.oplist.org.cn)
- 📘 [海外站点](https://doc.oplist.org)
- 📚 [备用站点](https://doc.openlist.team)
## Demo
## 演示
- 🌎 [全球 Demo](https://demo.oplist.org)
- 🇨🇳 [中国 Demo](https://demo.oplist.org.cn)
N/A(待重建)
## 讨论
如有一般性问题请前往 [*Discussions*](https://github.com/OpenListTeam/OpenList/discussions) 讨论区,***Issues* 仅用于错误报告和功能请求。**
## 赞助者
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## 捐赠者
感谢以下捐赠者的慷慨支持:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## 许可证
`OpenList` 是基于 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) 许可证的开源软件。
@@ -157,7 +142,7 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3
- 本软件按“原样”提供,不附带任何明示或暗示的担保,包括但不限于适销性或特定用途的适用性。
- 维护者不对因使用或无法使用本软件而导致的任何直接或间接损失负责。
- 您需自行承担使用本软件的所有风险,包括但不限于账号被封、下载限速等。
- 本项目遵循 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) 许可证,详情请参见 [LICENSE](../LICENSE) 文件。
- 本项目遵循 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) 许可证,详情请参见 [LICENSE](./LICENSE) 文件。
## 联系我们
+11 -26
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
<p><em>OpenList は、信頼ベースの攻撃からオープンソースを守るために構築された、レジリエントで長期ガバナンス、コミュニティ主導の AList フォークです。</em></p>
@@ -14,11 +14,11 @@
---
- [English](../README.md) | [中文](./README_cn.md) | 日本語 | [Dutch](./README_nl.md) | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md)
- [English](./README.md) | [中文](./README_cn.md) | 日本語 | [Dutch](./README_nl.md)
- [コントリビュート](../CONTRIBUTING.md)
- [行動規範](../CODE_OF_CONDUCT.md)
- [ライセンス](../LICENSE)
- [コントリビュート](./CONTRIBUTING.md)
- [行動規範](./CODE_OF_CONDUCT.md)
- [ライセンス](./LICENSE)
## 免責事項
@@ -94,7 +94,6 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk ドキュメント](https://alidocs.dingtalk.com/)
- [x] [MediaFire](https://www.mediafire.com)
- [x] 簡単にデプロイでき、すぐに使える
- [x] ファイルプレビュー(PDF、markdown、コード、テキストなど)
@@ -117,32 +116,18 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい
## ドキュメント
- 📘 [ドキュメント](https://doc.oplist.org)
- 🌏 [中国ミラー](https://doc.oplist.org.cn)
- ⚖️ [利用規約](https://doc.oplist.org/terms)
- 🔒 [プライバシーポリシー](https://doc.oplist.org/privacy)
- 📘 [グローバルサイト](https://doc.oplist.org)
- 📚 [バックアップサイト](https://doc.openlist.team)
- 🌏 [CNサイト](https://doc.oplist.org.cn)
## Demo
## デモ
- 🌎 [グローバル Demo](https://demo.oplist.org)
- 🇨🇳 [中国 Demo](https://demo.oplist.org.cn)
N/A(再構築中)
## ディスカッション
一般的な質問は [*Discussions*](https://github.com/OpenListTeam/OpenList/discussions) をご利用ください。***Issues* はバグ報告と機能リクエスト専用です。**
## スポンサー
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## 寄付者
以下の方々の寛大なご支援に感謝いたします:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## ライセンス
「OpenList」は [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) ライセンスの下で公開されているオープンソースソフトウェアです。
@@ -157,7 +142,7 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい
- 本ソフトウェアは「現状有姿」で提供されており、商品性や特定目的への適合性を含むいかなる保証もありません。
- 本ソフトウェアの使用または使用不能によるいかなる直接的・間接的損害についても、メンテナは責任を負いません。
- 本ソフトウェアの利用に伴うすべてのリスク(アカウントの凍結やダウンロード速度制限などを含む)は、利用者自身が負うものとします。
- 本プロジェクトは [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) ライセンスに従います。詳細は [LICENSE](../LICENSE) ファイルをご覧ください。
- 本プロジェクトは [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) ライセンスに従います。詳細は [LICENSE](./LICENSE) ファイルをご覧ください。
## お問い合わせ
+10 -25
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" width="128" height="128" alt="logo" />
<img style="width: 128px; height: 128px;" src="https://raw.githubusercontent.com/OpenListTeam/Logo/main/logo.svg" alt="logo" />
<p><em>OpenList is een veerkrachtige, langetermijn, door de gemeenschap geleide fork van AList — gebouwd om open source te beschermen tegen op vertrouwen gebaseerde aanvallen.</em></p>
@@ -14,11 +14,11 @@
---
- [English](../README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | Dutch | [한국어](./README_ko.md) | [Deutsch](./README_de.md) | [Русский](./README_ru.md) | [Français](./README_fr.md) | [Español](./README_es.md) | [العربية](./README_ar.md)
- [English](./README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | Dutch
- [Bijdragen](../CONTRIBUTING.md)
- [Gedragscode](../CODE_OF_CONDUCT.md)
- [Licentie](../LICENSE)
- [Bijdragen](./CONTRIBUTING.md)
- [Gedragscode](./CODE_OF_CONDUCT.md)
- [Licentie](./LICENSE)
## Disclaimer
@@ -95,7 +95,6 @@ Dank u voor uw ondersteuning en begrip
- [x] [OpenList](https://github.com/OpenListTeam/OpenList)
- [x] [Teldrive](https://github.com/tgdrive/teldrive)
- [x] [Weiyun](https://www.weiyun.com)
- [x] [DingTalk-documenten](https://alidocs.dingtalk.com/)
- [x] Eenvoudig te implementeren en direct te gebruiken
- [x] Bestandsvoorbeeld (PDF, markdown, code, platte tekst, ...)
- [x] Afbeeldingsvoorbeeld in galerijweergave
@@ -117,32 +116,18 @@ Dank u voor uw ondersteuning en begrip
## Documentatie
- 📘 [Documentatie](https://doc.oplist.org)
- 🌏 [CN Mirror](https://doc.oplist.org.cn)
- ⚖️ [Gebruiksvoorwaarden](https://doc.oplist.org/terms)
- 🔒 [Privacybeleid](https://doc.oplist.org/privacy)
- 📘 [Global Site](https://doc.oplist.org)
- 📚 [Backup Site](https://doc.openlist.team)
- 🌏 [CN Site](https://doc.oplist.org.cn)
## Demo
- 🌎 [Global Demo](https://demo.oplist.org)
- 🇨🇳 [CN Demo](https://demo.oplist.org.cn)
N.v.t. (wordt opnieuw opgebouwd)
## Discussie
Stel algemene vragen in [*Discussions*](https://github.com/OpenListTeam/OpenList/discussions), ***Issues* zijn alleen voor bugmeldingen en feature requests.**
## Sponsoren
[![VPS.Town](https://vps.town/static/images/sponsor.png)](https://vps.town "VPS.Town - Trust, Effortlessly. Your Cloud, Reimagined.")
## Donatie Bijdragers
Dank aan de volgende donateurs voor hun steun:
- [HisAtri](https://github.com/HisAtri)
- 爱发电用户_7jTh
- suka
## Licentie
`OpenList` is open-source software onder de [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) licentie.
@@ -157,7 +142,7 @@ Dank aan de volgende donateurs voor hun steun:
- De software wordt geleverd "zoals deze is", zonder enige vorm van garantie, expliciet of impliciet, inclusief maar niet beperkt tot garanties van verkoopbaarheid of geschiktheid voor een bepaald doel.
- De beheerders zijn niet aansprakelijk voor enige directe of indirecte schade die voortvloeit uit het gebruik van of het onvermogen om deze software te gebruiken.
- U bent zelf verantwoordelijk voor alle risico's die gepaard gaan met het gebruik van deze software, inclusief maar niet beperkt tot accountblokkades of downloadbeperkingen.
- Dit project valt onder de [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) licentie. Zie het [LICENSE](../LICENSE) bestand voor details.
- Dit project valt onder de [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) licentie. Zie het [LICENSE](./LICENSE) bestand voor details.
## Contact
-89
View File
@@ -1,89 +0,0 @@
# Security Policy
## Supported Versions
Only the latest stable release receives security patches. We strongly recommend always keeping OpenList up to date.
| Version | Supported |
| -------------------- | ------------------ |
| Latest stable (v4.x) | :white_check_mark: |
| Older versions | :x: |
## Reporting a Vulnerability
**Please do NOT report security vulnerabilities through public GitHub Issues.**
If you discover a security vulnerability in OpenList, please report it responsibly by using one of the following channels:
- **GitHub Private Security Advisory** (preferred): [Submit here](https://github.com/OpenListTeam/OpenList/security/advisories/new)
- **Telegram**: Contact a maintainer privately via [@OpenListTeam](https://t.me/OpenListTeam)
When reporting, please include as much of the following as possible:
- A description of the vulnerability and its potential impact
- The affected version(s)
- Step-by-step instructions to reproduce the issue
- Any proof-of-concept code or screenshots (if applicable)
- Suggested mitigation or fix (optional but appreciated)
## Security Best Practices for Users
To keep your OpenList instance secure:
- Always update to the latest release.
- Use a strong, unique admin password and change it after first login.
- Enable HTTPS (TLS) for your deployment — do **not** expose OpenList over plain HTTP on the public internet.
- Limit exposed ports using a reverse proxy (e.g., Nginx, Caddy).
- Set up access controls and avoid enabling guest access unless necessary.
- Regularly review mounted storage permissions and revoke unused API tokens.
- When using Docker, avoid running the container as root if possible.
## Acknowledgments
We sincerely thank all security researchers and community members who responsibly disclose vulnerabilities and help make OpenList safer for everyone.
---
# 安全政策
## 支持的版本
我们仅对最新稳定版本提供安全补丁。强烈建议始终保持 OpenList 为最新版本。
| 版本 | 是否支持 |
| ------------------ | ------------------ |
| 最新稳定版(v4.x) | :white_check_mark: |
| 旧版本 | :x: |
## 报告漏洞
**请勿通过公开的 GitHub Issues 报告安全漏洞。**
如果您在 OpenList 中发现安全漏洞,请通过以下渠道之一负责任地进行报告:
- **GitHub 私密安全公告**(推荐):[点击提交](https://github.com/OpenListTeam/OpenList/security/advisories/new)
- **Telegram**:通过 [@OpenListTeam](https://t.me/OpenListTeam) 私信联系维护者
报告时,请尽量提供以下信息:
- 漏洞描述及其潜在影响
- 受影响的版本
- 复现问题的详细步骤
- 概念验证代码或截图(如有)
- 建议的缓解措施或修复方案(可选,但非常欢迎)
## 用户安全最佳实践
为保障您的 OpenList 实例安全:
- 始终更新至最新版本。
- 使用强且唯一的管理员密码,并在首次登录后立即修改。
- 为您的部署启用 HTTPS(TLS)—— **请勿**在公网上以明文 HTTP 方式暴露 OpenList。
- 使用反向代理(如 Nginx、Caddy)限制对外暴露的端口。
- 配置访问控制,非必要情况下不要开启访客访问。
- 定期检查已挂载存储的权限,并撤销未使用的 API 令牌。
- 使用 Docker 部署时,尽可能避免以 root 用户运行容器。
## 致谢
我们衷心感谢所有负责任地披露漏洞、帮助 OpenList 变得更加安全的安全研究人员和社区成员。
+14 -73
View File
@@ -48,54 +48,6 @@ ldflags="\
-X 'github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=$webVersion' \
"
# Keep sqlite driver tag selection centralized to avoid target drift.
GetBuildTagsForTarget() {
local target="$1"
case "$target" in
linux-loong64|linux-mips|linux-mips64|linux-mips64le|linux-mipsle|linux-musl-loong64|linux-musl-mips|linux-musl-mips64|linux-musl-mips64le|linux-musl-mipsle|windows-386|windows7-386|windows7-amd64)
echo "jsoniter,sqlite_cgo_compat"
;;
*)
echo "jsoniter"
;;
esac
}
# Keep musl static link flags centralized for all musl build paths.
GetMuslStaticLdflags() {
echo "-linkmode external -extldflags '-static -fpic' $ldflags"
}
# Fail fast if a musl build artifact is not fully static.
AssertStaticBinary() {
local binary="$1"
if [ ! -f "$binary" ]; then
echo "Error: binary not found: $binary"
return 1
fi
if command -v readelf >/dev/null 2>&1; then
if readelf -l "$binary" 2>/dev/null | grep -q "Requesting program interpreter"; then
echo "Error: binary is not fully static: $binary"
readelf -l "$binary" | grep "Requesting program interpreter" || true
return 1
fi
return 0
fi
if command -v file >/dev/null 2>&1; then
if file "$binary" | grep -qi "dynamically linked"; then
echo "Error: binary is dynamically linked: $binary"
file "$binary"
return 1
fi
return 0
fi
echo "Warning: readelf/file not found, skip static verification for $binary"
return 0
}
FetchWebRolling() {
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/rolling\"")
pre_release_assets=$(echo "$pre_release_json" | jq -r '.assets[].browser_download_url')
@@ -158,7 +110,6 @@ BuildWin7() {
# Build for both 386 and amd64 architectures
for arch in "386" "amd64"; do
echo "building for windows7-${arch}"
build_tags=$(GetBuildTagsForTarget "windows7-${arch}")
export GOOS=windows
export GOARCH=${arch}
export CGO_ENABLED=1
@@ -173,14 +124,14 @@ BuildWin7() {
fi
# Use the patched Go compiler for Win7 compatibility
$(pwd)/go-win7/bin/go build -o "${1}-${arch}.exe" -ldflags="$ldflags" -tags="$build_tags" .
$(pwd)/go-win7/bin/go build -o "${1}-${arch}.exe" -ldflags="$ldflags" -tags=jsoniter .
done
}
BuildDev() {
rm -rf .git/
mkdir -p "dist"
muslflags="$(GetMuslStaticLdflags)"
muslflags="--extldflags '-static -fpic' $ldflags"
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross)
for i in "${FILES[@]}"; do
@@ -198,8 +149,7 @@ BuildDev() {
export GOARCH=${os_arch##*-}
export CC=${cgo_cc}
export CGO_ENABLED=1
CGO_LDFLAGS="-static" go build -o ./dist/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
AssertStaticBinary "./dist/$appName-$os_arch"
go build -o ./dist/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
done
xgo -targets=windows/amd64,darwin/amd64,darwin/arm64 -out "$appName" -ldflags="$ldflags" -tags=jsoniter .
mv "$appName"-* dist
@@ -233,7 +183,7 @@ BuildDockerMultiplatform() {
# run PrepareBuildDockerMusl before build
export PATH=$PATH:$PWD/build/musl-libs/bin
docker_lflags="$(GetMuslStaticLdflags)"
docker_lflags="--extldflags '-static -fpic' $ldflags"
export CGO_ENABLED=1
OS_ARCHES=(linux-amd64 linux-arm64 linux-386 linux-riscv64 linux-ppc64le linux-loong64) ## Disable linux-s390x builds
@@ -243,13 +193,11 @@ BuildDockerMultiplatform() {
cgo_cc=${CGO_ARGS[$i]}
os=${os_arch%%-*}
arch=${os_arch##*-}
build_tags=$(GetBuildTagsForTarget "$os_arch")
export GOOS=$os
export GOARCH=$arch
export CC=${cgo_cc}
echo "building for $os_arch"
CGO_LDFLAGS="-static" go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags="$build_tags" .
AssertStaticBinary "build/$os/$arch/$appName"
go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags=jsoniter .
done
DOCKER_ARM_ARCHES=(linux-arm/v6 linux-arm/v7)
@@ -263,8 +211,7 @@ BuildDockerMultiplatform() {
export GOARM=${GO_ARM[$i]}
export CC=${cgo_cc}
echo "building for $docker_arch"
CGO_LDFLAGS="-static" go build -o build/${docker_arch%%-*}/${docker_arch##*-}/"$appName" -ldflags="$docker_lflags" -tags=jsoniter .
AssertStaticBinary "build/${docker_arch%%-*}/${docker_arch##*-}/$appName"
go build -o build/${docker_arch%%-*}/${docker_arch##*-}/"$appName" -ldflags="$docker_lflags" -tags=jsoniter .
done
}
@@ -290,8 +237,6 @@ BuildLoongGLIBC() {
local target_abi="$2"
local output_file="$1"
local oldWorldGoVersion="1.25.0"
local loong_tags
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
if [ "$target_abi" = "abi1.0" ]; then
echo building for linux-loong64-abi1.0
@@ -366,7 +311,7 @@ BuildLoongGLIBC() {
CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \
CGO_ENABLED=1 \
GOCACHE="$abi1_cache_dir" \
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
echo "Error: Build failed with patched Go compiler"
echo "Attempting retry with cache cleanup..."
env GOCACHE="$abi1_cache_dir" $(pwd)/go-loong64-abi1.0/bin/go clean -cache
@@ -375,7 +320,7 @@ BuildLoongGLIBC() {
CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \
CGO_ENABLED=1 \
GOCACHE="$abi1_cache_dir" \
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
echo "Error: Build failed again after cache cleanup"
echo "Build environment details:"
echo "GOOS=linux"
@@ -421,11 +366,11 @@ BuildLoongGLIBC() {
# Use standard Go compiler for new-world build
echo "Building with standard Go compiler for new-world ABI2.0..."
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
echo "Error: Build failed with standard Go compiler"
echo "Attempting retry with cache cleanup..."
go clean -cache
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
echo "Error: Build failed again after cache cleanup"
echo "Build environment details:"
echo "GOOS=$GOOS"
@@ -444,9 +389,8 @@ BuildLoongGLIBC() {
BuildReleaseLinuxMusl() {
rm -rf .git/
mkdir -p "build"
muslflags="$(GetMuslStaticLdflags)"
muslflags="--extldflags '-static -fpic' $ldflags"
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
# Keep mips-family targets enabled; sqlite driver selection is handled by Go build tags.
FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross mips-linux-musl-cross mips64-linux-musl-cross mips64el-linux-musl-cross mipsel-linux-musl-cross powerpc64le-linux-musl-cross s390x-linux-musl-cross loongarch64-linux-musl-cross)
for i in "${FILES[@]}"; do
url="${BASE}${i}.tgz"
@@ -459,21 +403,19 @@ BuildReleaseLinuxMusl() {
for i in "${!OS_ARCHES[@]}"; do
os_arch=${OS_ARCHES[$i]}
cgo_cc=${CGO_ARGS[$i]}
build_tags=$(GetBuildTagsForTarget "$os_arch")
echo building for ${os_arch}
export GOOS=${os_arch%%-*}
export GOARCH=${os_arch##*-}
export CC=${cgo_cc}
export CGO_ENABLED=1
CGO_LDFLAGS="-static" go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags="$build_tags" .
AssertStaticBinary "./build/$appName-$os_arch"
go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
done
}
BuildReleaseLinuxMuslArm() {
rm -rf .git/
mkdir -p "build"
muslflags="$(GetMuslStaticLdflags)"
muslflags="--extldflags '-static -fpic' $ldflags"
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
FILES=(arm-linux-musleabi-cross arm-linux-musleabihf-cross armel-linux-musleabi-cross armel-linux-musleabihf-cross armv5l-linux-musleabi-cross armv5l-linux-musleabihf-cross armv6-linux-musleabi-cross armv6-linux-musleabihf-cross armv7l-linux-musleabihf-cross armv7m-linux-musleabi-cross armv7r-linux-musleabihf-cross)
for i in "${FILES[@]}"; do
@@ -495,8 +437,7 @@ BuildReleaseLinuxMuslArm() {
export CC=${cgo_cc}
export CGO_ENABLED=1
export GOARM=${arm}
CGO_LDFLAGS="-static" go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
AssertStaticBinary "./build/$appName-$os_arch"
go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
done
}
+6 -7
View File
@@ -6,7 +6,6 @@ package cmd
import (
"fmt"
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
@@ -21,8 +20,8 @@ var AdminCmd = &cobra.Command{
Aliases: []string{"password"},
Short: "Show admin user's info and some operations about admin user's password",
Run: func(cmd *cobra.Command, args []string) {
bootstrap.Init()
defer bootstrap.Release()
Init()
defer Release()
admin, err := op.GetAdmin()
if err != nil {
utils.Log.Errorf("failed get admin user: %+v", err)
@@ -62,8 +61,8 @@ var ShowTokenCmd = &cobra.Command{
Use: "token",
Short: "Show admin token",
Run: func(cmd *cobra.Command, args []string) {
bootstrap.Init()
defer bootstrap.Release()
Init()
defer Release()
token := setting.GetStr(conf.Token)
utils.Log.Infof("show admin token from CLI")
fmt.Println("Admin token:", token)
@@ -71,8 +70,8 @@ var ShowTokenCmd = &cobra.Command{
}
func setAdminPassword(pwd string) {
bootstrap.Init()
defer bootstrap.Release()
Init()
defer Release()
admin, err := op.GetAdmin()
if err != nil {
utils.Log.Errorf("failed get admin user: %+v", err)
+2 -3
View File
@@ -6,7 +6,6 @@ package cmd
import (
"fmt"
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/spf13/cobra"
@@ -17,8 +16,8 @@ var Cancel2FACmd = &cobra.Command{
Use: "cancel2fa",
Short: "Delete 2FA of admin user",
Run: func(cmd *cobra.Command, args []string) {
bootstrap.Init()
defer bootstrap.Release()
Init()
defer Release()
admin, err := op.GetAdmin()
if err != nil {
utils.Log.Errorf("failed to get admin user: %+v", err)
+11 -2
View File
@@ -6,16 +6,25 @@ import (
"strconv"
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap/data"
"github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
log "github.com/sirupsen/logrus"
)
func Init() {
bootstrap.Init()
bootstrap.InitConfig()
bootstrap.Log()
bootstrap.InitDB()
data.InitData()
bootstrap.InitPlugins()
bootstrap.InitStreamLimit()
bootstrap.InitIndex()
bootstrap.InitUpgradePatch()
}
func Release() {
bootstrap.Release()
db.Close()
}
var pid = -1
+4 -2
View File
@@ -1,17 +1,19 @@
package cmd
import (
log "github.com/sirupsen/logrus"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/spf13/cobra"
rcCrypt "github.com/rclone/rclone/backend/crypt"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/config/obscure"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// encryption and decryption command format for Crypt driver
+3 -20
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
"reflect"
"strings"
_ "github.com/OpenListTeam/OpenList/v4/drivers"
@@ -68,33 +69,15 @@ func writeFile(name string, data interface{}) {
log.Errorf("failed to unmarshal json: %+v", err)
return
}
if mergeJson(newData, oldData) {
if reflect.DeepEqual(oldData, newData) {
log.Infof("%s.json no changed, skip", name)
} else {
log.Infof("%s.json changed, update file", name)
//log.Infof("old: %+v\nnew:%+v", oldData, data)
utils.WriteJsonToFile(fmt.Sprintf("lang/%s.json", name), oldData, true)
utils.WriteJsonToFile(fmt.Sprintf("lang/%s.json", name), newData, true)
}
}
func mergeJson(source, target map[string]interface{}) bool {
equal := true
for k, v := range source {
tgtV, tgtOk := target[k]
if !tgtOk {
equal = false
target[k] = v
} else {
srcMap, srcIsMap := v.(map[string]interface{})
tgtMap, tgtIsMap := tgtV.(map[string]interface{})
if srcIsMap && tgtIsMap {
equal = mergeJson(srcMap, tgtMap) && equal
}
}
}
return equal
}
func generateDriversJson() {
drivers := make(Drivers)
drivers["drivers"] = make(KV[interface{}])
+239 -4
View File
@@ -1,13 +1,34 @@
package cmd
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
"github.com/OpenListTeam/OpenList/v4/cmd/flags"
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/fs"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server"
"github.com/OpenListTeam/OpenList/v4/server/middlewares"
"github.com/OpenListTeam/sftpd-openlist"
ftpserver "github.com/fclairamb/ftpserverlib"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"github.com/quic-go/quic-go/http3"
)
// ServerCmd represents the server command
@@ -17,9 +38,161 @@ var ServerCmd = &cobra.Command{
Long: `Start the server at the specified address
the address is defined in config file`,
Run: func(cmd *cobra.Command, args []string) {
bootstrap.Init()
defer bootstrap.Release()
bootstrap.Start()
Init()
if conf.Conf.DelayedStart != 0 {
utils.Log.Infof("delayed start for %d seconds", conf.Conf.DelayedStart)
time.Sleep(time.Duration(conf.Conf.DelayedStart) * time.Second)
}
bootstrap.InitOfflineDownloadTools()
bootstrap.LoadStorages()
bootstrap.InitTaskManager()
if !flags.Debug && !flags.Dev {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
// gin log
if conf.Conf.Log.Filter.Enable {
r.Use(middlewares.FilteredLogger())
} else {
r.Use(gin.LoggerWithWriter(log.StandardLogger().Out))
}
r.Use(gin.RecoveryWithWriter(log.StandardLogger().Out))
server.Init(r)
var httpHandler http.Handler = r
if conf.Conf.Scheme.EnableH2c {
httpHandler = h2c.NewHandler(r, &http2.Server{})
}
var httpSrv, httpsSrv, unixSrv *http.Server
var quicSrv *http3.Server
if conf.Conf.Scheme.HttpPort != -1 {
httpBase := fmt.Sprintf("%s:%d", conf.Conf.Scheme.Address, conf.Conf.Scheme.HttpPort)
fmt.Printf("start HTTP server @ %s\n", httpBase)
utils.Log.Infof("start HTTP server @ %s", httpBase)
httpSrv = &http.Server{Addr: httpBase, Handler: httpHandler}
go func() {
err := httpSrv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
utils.Log.Fatalf("failed to start http: %s", err.Error())
}
}()
}
if conf.Conf.Scheme.HttpsPort != -1 {
httpsBase := fmt.Sprintf("%s:%d", conf.Conf.Scheme.Address, conf.Conf.Scheme.HttpsPort)
fmt.Printf("start HTTPS server @ %s\n", httpsBase)
utils.Log.Infof("start HTTPS server @ %s", httpsBase)
httpsSrv = &http.Server{Addr: httpsBase, Handler: r}
go func() {
err := httpsSrv.ListenAndServeTLS(conf.Conf.Scheme.CertFile, conf.Conf.Scheme.KeyFile)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
utils.Log.Fatalf("failed to start https: %s", err.Error())
}
}()
if conf.Conf.Scheme.EnableH3 {
fmt.Printf("start HTTP3 (quic) server @ %s\n", httpsBase)
utils.Log.Infof("start HTTP3 (quic) server @ %s", httpsBase)
r.Use(func(c *gin.Context) {
if c.Request.TLS != nil {
port := conf.Conf.Scheme.HttpsPort
c.Header("Alt-Svc", fmt.Sprintf("h3=\":%d\"; ma=86400", port))
}
c.Next()
})
quicSrv = &http3.Server{Addr: httpsBase, Handler: r}
go func() {
err := quicSrv.ListenAndServeTLS(conf.Conf.Scheme.CertFile, conf.Conf.Scheme.KeyFile)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
utils.Log.Fatalf("failed to start http3 (quic): %s", err.Error())
}
}()
}
}
if conf.Conf.Scheme.UnixFile != "" {
fmt.Printf("start unix server @ %s\n", conf.Conf.Scheme.UnixFile)
utils.Log.Infof("start unix server @ %s", conf.Conf.Scheme.UnixFile)
unixSrv = &http.Server{Handler: httpHandler}
go func() {
listener, err := net.Listen("unix", conf.Conf.Scheme.UnixFile)
if err != nil {
utils.Log.Fatalf("failed to listen unix: %+v", err)
}
// set socket file permission
mode, err := strconv.ParseUint(conf.Conf.Scheme.UnixFilePerm, 8, 32)
if err != nil {
utils.Log.Errorf("failed to parse socket file permission: %+v", err)
} else {
err = os.Chmod(conf.Conf.Scheme.UnixFile, os.FileMode(mode))
if err != nil {
utils.Log.Errorf("failed to chmod socket file: %+v", err)
}
}
err = unixSrv.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
utils.Log.Fatalf("failed to start unix: %s", err.Error())
}
}()
}
if conf.Conf.S3.Port != -1 && conf.Conf.S3.Enable {
s3r := gin.New()
s3r.Use(gin.LoggerWithWriter(log.StandardLogger().Out), gin.RecoveryWithWriter(log.StandardLogger().Out))
server.InitS3(s3r)
s3Base := fmt.Sprintf("%s:%d", conf.Conf.Scheme.Address, conf.Conf.S3.Port)
fmt.Printf("start S3 server @ %s\n", s3Base)
utils.Log.Infof("start S3 server @ %s", s3Base)
go func() {
var err error
if conf.Conf.S3.SSL {
httpsSrv = &http.Server{Addr: s3Base, Handler: s3r}
err = httpsSrv.ListenAndServeTLS(conf.Conf.Scheme.CertFile, conf.Conf.Scheme.KeyFile)
}
if !conf.Conf.S3.SSL {
httpSrv = &http.Server{Addr: s3Base, Handler: s3r}
err = httpSrv.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
utils.Log.Fatalf("failed to start s3 server: %s", err.Error())
}
}()
}
var ftpDriver *server.FtpMainDriver
var ftpServer *ftpserver.FtpServer
if conf.Conf.FTP.Listen != "" && conf.Conf.FTP.Enable {
var err error
ftpDriver, err = server.NewMainDriver()
if err != nil {
utils.Log.Fatalf("failed to start ftp driver: %s", err.Error())
} else {
fmt.Printf("start ftp server on %s\n", conf.Conf.FTP.Listen)
utils.Log.Infof("start ftp server on %s", conf.Conf.FTP.Listen)
go func() {
ftpServer = ftpserver.NewFtpServer(ftpDriver)
err = ftpServer.ListenAndServe()
if err != nil {
utils.Log.Fatalf("problem ftp server listening: %s", err.Error())
}
}()
}
}
var sftpDriver *server.SftpDriver
var sftpServer *sftpd.SftpServer
if conf.Conf.SFTP.Listen != "" && conf.Conf.SFTP.Enable {
var err error
sftpDriver, err = server.NewSftpDriver()
if err != nil {
utils.Log.Fatalf("failed to start sftp driver: %s", err.Error())
} else {
fmt.Printf("start sftp server on %s", conf.Conf.SFTP.Listen)
utils.Log.Infof("start sftp server on %s", conf.Conf.SFTP.Listen)
go func() {
sftpServer = sftpd.NewSftpServer(sftpDriver)
err = sftpServer.RunServer()
if err != nil {
utils.Log.Fatalf("problem sftp server listening: %s", err.Error())
}
}()
}
}
// Wait for interrupt signal to gracefully shutdown the server with
// a timeout of 1 second.
quit := make(chan os.Signal, 1)
@@ -28,7 +201,69 @@ the address is defined in config file`,
// kill -9 is syscall. SIGKILL but can"t be catch, so don't need add it
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
bootstrap.Shutdown(1 * time.Second)
utils.Log.Println("Shutdown server...")
fs.ArchiveContentUploadTaskManager.RemoveAll()
Release()
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
var wg sync.WaitGroup
if conf.Conf.Scheme.HttpPort != -1 {
wg.Add(1)
go func() {
defer wg.Done()
if err := httpSrv.Shutdown(ctx); err != nil {
utils.Log.Fatal("HTTP server shutdown err: ", err)
}
}()
}
if conf.Conf.Scheme.HttpsPort != -1 {
wg.Add(1)
go func() {
defer wg.Done()
if err := httpsSrv.Shutdown(ctx); err != nil {
utils.Log.Fatal("HTTPS server shutdown err: ", err)
}
}()
if conf.Conf.Scheme.EnableH3 {
wg.Add(1)
go func() {
defer wg.Done()
if err := quicSrv.Shutdown(ctx); err != nil {
utils.Log.Fatal("HTTP3 (quic) server shutdown err: ", err)
}
}()
}
}
if conf.Conf.Scheme.UnixFile != "" {
wg.Add(1)
go func() {
defer wg.Done()
if err := unixSrv.Shutdown(ctx); err != nil {
utils.Log.Fatal("Unix server shutdown err: ", err)
}
}()
}
if conf.Conf.FTP.Listen != "" && conf.Conf.FTP.Enable && ftpServer != nil && ftpDriver != nil {
wg.Add(1)
go func() {
defer wg.Done()
ftpDriver.Stop()
if err := ftpServer.Stop(); err != nil {
utils.Log.Fatal("FTP server shutdown err: ", err)
}
}()
}
if conf.Conf.SFTP.Listen != "" && conf.Conf.SFTP.Enable && sftpServer != nil && sftpDriver != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := sftpServer.Close(); err != nil {
utils.Log.Fatal("SFTP server shutdown err: ", err)
}
}()
}
wg.Wait()
utils.Log.Println("Server exit")
},
}
+6 -7
View File
@@ -8,7 +8,6 @@ import (
"os"
"strconv"
"github.com/OpenListTeam/OpenList/v4/internal/bootstrap"
"github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/charmbracelet/bubbles/table"
@@ -31,8 +30,8 @@ var disableStorageCmd = &cobra.Command{
return fmt.Errorf("mount path is required")
}
mountPath := args[0]
bootstrap.Init()
defer bootstrap.Release()
Init()
defer Release()
storage, err := db.GetStorageByMountPath(mountPath)
if err != nil {
return fmt.Errorf("failed to query storage: %+v", err)
@@ -70,8 +69,8 @@ var deleteStorageCmd = &cobra.Command{
}
}
bootstrap.Init()
defer bootstrap.Release()
Init()
defer Release()
err = db.DeleteStorageById(uint(id))
if err != nil {
return fmt.Errorf("failed to delete storage by id: %+v", err)
@@ -124,8 +123,8 @@ var listStorageCmd = &cobra.Command{
Use: "list",
Short: "List all storages",
RunE: func(cmd *cobra.Command, args []string) error {
bootstrap.Init()
defer bootstrap.Release()
Init()
defer Release()
storages, _, err := db.GetStorages(1, -1)
if err != nil {
return fmt.Errorf("failed to query storages: %+v", err)
-565
View File
@@ -1,565 +0,0 @@
// Command openlist-transcode-worker 是独立的远程转码 Worker 节点。
// 它通过 OpenList 自研协议(HTTP/JSON)注册到 Master,长轮询领任务,调用
// 本机 ffmpeg 转码,并把生成的 HLS 切片实时上传回 Master。
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
var (
masterURL = flag.String("master", "http://127.0.0.1:5244", "OpenList Master URL")
secret = flag.String("secret", "", "Worker 共享密钥(与 Master 配置一致)")
name = flag.String("name", hostname(), "Worker 名称")
capacity = flag.Int("capacity", 2, "并发任务数")
hwaccel = flag.String("hwaccel", "none", "硬件加速:none/auto/nvenc/qsv/vaapi/amf/videotoolbox")
workdir = flag.String("workdir", "./worker_tmp", "临时工作目录")
ffmpeg = flag.String("ffmpeg", "ffmpeg", "ffmpeg 路径")
)
func hostname() string {
h, _ := os.Hostname()
if h == "" {
h = "worker"
}
return h
}
// ---- 协议结构 ----(与 Master 端 internal/transcode/protocol.go 一致)
type RegisterReq struct {
Name string `json:"name"`
Version string `json:"version"`
Capacity int `json:"capacity"`
HWAccel []string `json:"hwaccel"`
CodecsDecode []string `json:"codecs_decode"`
CodecsEncode []string `json:"codecs_encode"`
MaxResolution string `json:"max_resolution"`
Tags []string `json:"tags"`
}
type RegisterResp struct {
Data struct {
WorkerID string `json:"worker_id"`
HeartbeatInterval int `json:"heartbeat_interval"`
} `json:"data"`
}
type HeartbeatReq struct {
WorkerID string `json:"worker_id"`
Load float64 `json:"load"`
Running []string `json:"running"`
FreeSlots int `json:"free_slots"`
}
type HeartbeatResp struct {
Data struct {
OK bool `json:"ok"`
Kick bool `json:"kick"`
Cancel []string `json:"cancel"`
} `json:"data"`
}
type ClaimReq struct {
WorkerID string `json:"worker_id"`
Slots int `json:"slots"`
Wait int `json:"wait"`
}
type Profile struct {
Name string `json:"name"`
VideoCodec string `json:"video_codec"`
VideoBitrate string `json:"video_bitrate"`
Scale string `json:"scale"`
AudioCodec string `json:"audio_codec"`
AudioBitrate string `json:"audio_bitrate"`
HWAccel string `json:"hwaccel"`
}
type OutputSpec struct {
Format string `json:"format"`
SegmentDuration int `json:"segment_duration"`
}
type Job struct {
ID string `json:"id"`
Path string `json:"path"`
SourceURL string `json:"source_url"`
Profiles []Profile `json:"profiles"`
Output OutputSpec `json:"output"`
CallbackToken string `json:"callback_token"`
Deadline int64 `json:"deadline"`
}
type ClaimResp struct {
Data struct {
Jobs []Job `json:"jobs"`
} `json:"data"`
}
type FinishReq struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Stats struct {
Elapsed float64 `json:"elapsed"`
BytesOut int64 `json:"bytes_out"`
} `json:"stats"`
}
// ---- HTTP helper ----
var httpClient = &http.Client{Timeout: 60 * time.Second}
func postJSON(path string, body any, out any, bearer string) error {
buf, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, *masterURL+path, bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// ---- main ----
func main() {
flag.Parse()
if *secret == "" {
fmt.Fprintln(os.Stderr, "missing -secret")
os.Exit(1)
}
if err := os.MkdirAll(*workdir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
workerID, hbInterval, err := registerOnce()
if err != nil {
fmt.Fprintln(os.Stderr, "register failed:", err)
os.Exit(1)
}
fmt.Printf("[worker] registered: id=%s heartbeat=%ds\n", workerID, hbInterval)
ctx, cancel := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Println("[worker] shutting down")
cancel()
}()
state := &workerState{
id: workerID,
runningMu: sync.Mutex{},
running: map[string]context.CancelFunc{},
}
// 心跳协程
go func() {
t := time.NewTicker(time.Duration(hbInterval) * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
state.heartbeat()
}
}
}()
// 工作协程
for i := 0; i < *capacity; i++ {
go state.workLoop(ctx)
}
<-ctx.Done()
time.Sleep(500 * time.Millisecond)
}
func registerOnce() (string, int, error) {
req := RegisterReq{
Name: *name,
Version: "1.0.0",
Capacity: *capacity,
HWAccel: []string{*hwaccel},
CodecsDecode: []string{"h264", "hevc", "av1", "vp9", "mpeg4", "mpeg2video"},
CodecsEncode: []string{"h264", "hevc"},
MaxResolution: "3840x2160",
Tags: []string{runtime.GOOS, runtime.GOARCH},
}
var resp RegisterResp
if err := postJSON("/api/transcode/worker/register", req, &resp, *secret); err != nil {
return "", 0, err
}
if resp.Data.WorkerID == "" {
return "", 0, fmt.Errorf("master returned empty worker_id")
}
if resp.Data.HeartbeatInterval == 0 {
resp.Data.HeartbeatInterval = 10
}
return resp.Data.WorkerID, resp.Data.HeartbeatInterval, nil
}
// ---- 状态机 ----
type workerState struct {
id string
runningMu sync.Mutex
running map[string]context.CancelFunc
}
func (s *workerState) heartbeat() {
s.runningMu.Lock()
ids := make([]string, 0, len(s.running))
for id := range s.running {
ids = append(ids, id)
}
free := *capacity - len(ids)
s.runningMu.Unlock()
var resp HeartbeatResp
if err := postJSON("/api/transcode/worker/heartbeat", HeartbeatReq{
WorkerID: s.id,
Running: ids,
FreeSlots: free,
}, &resp, *secret); err != nil {
fmt.Println("[worker] heartbeat error:", err)
return
}
for _, jid := range resp.Data.Cancel {
s.runningMu.Lock()
if cancel, ok := s.running[jid]; ok {
cancel()
}
s.runningMu.Unlock()
}
}
func (s *workerState) workLoop(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
var resp ClaimResp
err := postJSON("/api/transcode/worker/claim", ClaimReq{
WorkerID: s.id, Slots: 1, Wait: 20,
}, &resp, *secret)
if err != nil {
fmt.Println("[worker] claim error:", err)
time.Sleep(5 * time.Second)
continue
}
if len(resp.Data.Jobs) == 0 {
continue
}
for _, j := range resp.Data.Jobs {
s.runJob(ctx, j)
}
}
}
func (s *workerState) runJob(parent context.Context, job Job) {
jobCtx, cancel := context.WithCancel(parent)
s.runningMu.Lock()
s.running[job.ID] = cancel
s.runningMu.Unlock()
defer func() {
s.runningMu.Lock()
delete(s.running, job.ID)
s.runningMu.Unlock()
cancel()
}()
startedAt := time.Now()
tmpDir := filepath.Join(*workdir, job.ID)
_ = os.MkdirAll(tmpDir, 0o755)
defer os.RemoveAll(tmpDir)
if len(job.Profiles) == 0 {
s.finish(job, "failed", "no profiles", 0, 0)
return
}
profile := job.Profiles[0]
playlist := filepath.Join(tmpDir, "playlist.m3u8")
segPattern := filepath.Join(tmpDir, "seg-%d.ts")
args := buildFFmpegArgs(job, profile, segPattern, playlist)
cmd := exec.CommandContext(jobCtx, *ffmpeg, args...)
stderr := &bytes.Buffer{}
cmd.Stderr = stderr
fmt.Printf("[worker] running job %s, ffmpeg %s\n", job.ID, strings.Join(args, " "))
if err := cmd.Start(); err != nil {
s.finish(job, "failed", err.Error(), 0, 0)
return
}
// 上传协程
stopUpload := make(chan struct{})
uploadDone := make(chan struct{})
go func() {
defer close(uploadDone)
s.watchAndUpload(jobCtx, job, profile.Name, tmpDir, stopUpload)
}()
err := cmd.Wait()
close(stopUpload)
<-uploadDone
// 最后一次扫描:把还未上传的尾片传上去
s.uploadOnce(job, profile.Name, tmpDir, true)
if err != nil {
if jobCtx.Err() == context.Canceled {
s.finish(job, "cancelled", "cancelled by master", 0, 0)
return
}
s.finish(job, "failed", fmt.Sprintf("%v: %s", err, truncate(stderr.String(), 1024)), 0, 0)
return
}
s.finish(job, "finished", "", time.Since(startedAt).Seconds(), 0)
}
func (s *workerState) finish(job Job, status, errStr string, elapsed float64, bytesOut int64) {
req := FinishReq{
JobID: job.ID, Status: status, Error: errStr,
}
req.Stats.Elapsed = elapsed
req.Stats.BytesOut = bytesOut
if err := postJSON("/api/transcode/worker/job/finish", req, nil, job.CallbackToken); err != nil {
fmt.Printf("[worker] finish report failed: %v\n", err)
}
}
func (s *workerState) watchAndUpload(_ context.Context, job Job, profile, dir string, stop chan struct{}) {
t := time.NewTicker(800 * time.Millisecond)
defer t.Stop()
uploaded := map[int]bool{}
flush := func(final bool) {
s.uploadDelta(job, profile, dir, uploaded, final)
}
for {
select {
case <-stop:
flush(false)
return
case <-t.C:
flush(false)
}
}
}
func (s *workerState) uploadOnce(job Job, profile, dir string, final bool) {
uploaded := map[int]bool{}
s.uploadDelta(job, profile, dir, uploaded, final)
}
// uploadDelta 扫描目录,上传新切片(uploaded 跟踪已传过的 seq;最后一片到 final 时才上传)
func (s *workerState) uploadDelta(job Job, profile, dir string, uploaded map[int]bool, final bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type segFile struct {
seq int
path string
}
var segs []segFile
for _, e := range entries {
if e.IsDir() {
continue
}
n := e.Name()
if !strings.HasPrefix(n, "seg-") || !strings.HasSuffix(n, ".ts") {
continue
}
num, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(n, "seg-"), ".ts"))
if err != nil {
continue
}
segs = append(segs, segFile{num, filepath.Join(dir, n)})
}
// 排序
for i := 0; i < len(segs); i++ {
for j := i + 1; j < len(segs); j++ {
if segs[j].seq < segs[i].seq {
segs[i], segs[j] = segs[j], segs[i]
}
}
}
limit := len(segs)
if !final && limit > 0 {
limit-- // 最后一片可能还在写,留到下一轮
}
segDur := job.Output.SegmentDuration
if segDur == 0 {
segDur = 6
}
for i := 0; i < limit; i++ {
seg := segs[i]
if uploaded[seg.seq] {
continue
}
isLast := final && i == limit-1
if err := s.uploadSegment(job, profile, seg.seq, float64(segDur), seg.path, isLast); err != nil {
fmt.Printf("[worker] upload seg-%d failed: %v\n", seg.seq, err)
return
}
uploaded[seg.seq] = true
}
}
func (s *workerState) uploadSegment(job Job, profile string, seq int, dur float64, file string, final bool) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
q := fmt.Sprintf("?job=%s&profile=%s&seq=%d&duration=%f&final=%t",
job.ID, profile, seq, dur, final)
req, _ := http.NewRequest(http.MethodPut, *masterURL+"/api/transcode/worker/segment"+q, f)
req.Header.Set("Content-Type", "video/mp2t")
req.Header.Set("Authorization", "Bearer "+job.CallbackToken)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
}
return nil
}
// ---- ffmpeg args(与 master 端 local_runner 保持一致)----
func buildFFmpegArgs(job Job, profile Profile, segPattern, playlist string) []string {
hw := strings.ToLower(profile.HWAccel)
args := []string{"-y", "-loglevel", "warning"}
switch hw {
case "nvenc":
args = append(args, "-hwaccel", "cuda")
case "qsv":
args = append(args, "-hwaccel", "qsv")
case "vaapi":
args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", "/dev/dri/renderD128")
case "amf":
args = append(args, "-hwaccel", "d3d11va")
case "videotoolbox":
args = append(args, "-hwaccel", "videotoolbox")
case "auto":
args = append(args, "-hwaccel", "auto")
}
args = append(args, "-i", job.SourceURL)
args = append(args, "-c:v", pickEncoder(profile.VideoCodec, hw))
if profile.VideoBitrate != "" {
args = append(args, "-b:v", profile.VideoBitrate, "-maxrate", profile.VideoBitrate, "-bufsize", profile.VideoBitrate)
}
if profile.Scale != "" {
switch hw {
case "vaapi":
args = append(args, "-vf", "scale_vaapi="+profile.Scale)
case "qsv":
args = append(args, "-vf", "scale_qsv="+profile.Scale)
default:
args = append(args, "-vf", "scale="+profile.Scale)
}
}
if profile.AudioCodec == "copy" {
args = append(args, "-c:a", "copy")
} else {
ac := profile.AudioCodec
if ac == "" {
ac = "aac"
}
args = append(args, "-c:a", ac)
if profile.AudioBitrate != "" {
args = append(args, "-b:a", profile.AudioBitrate)
}
}
segDur := job.Output.SegmentDuration
if segDur == 0 {
segDur = 6
}
args = append(args,
"-f", "hls",
"-hls_time", strconv.Itoa(segDur),
"-hls_playlist_type", "vod",
"-hls_segment_filename", segPattern,
"-hls_flags", "independent_segments",
playlist,
)
return args
}
func pickEncoder(codec, hw string) string {
codec = strings.ToLower(codec)
switch codec {
case "h264", "":
switch hw {
case "nvenc":
return "h264_nvenc"
case "qsv":
return "h264_qsv"
case "vaapi":
return "h264_vaapi"
case "amf":
return "h264_amf"
case "videotoolbox":
return "h264_videotoolbox"
}
return "libx264"
case "hevc", "h265":
switch hw {
case "nvenc":
return "hevc_nvenc"
case "qsv":
return "hevc_qsv"
case "vaapi":
return "hevc_vaapi"
case "amf":
return "hevc_amf"
case "videotoolbox":
return "hevc_videotoolbox"
}
return "libx265"
case "av1":
switch hw {
case "nvenc":
return "av1_nvenc"
case "qsv":
return "av1_qsv"
}
return "libsvtav1"
}
return "libx264"
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
-167
View File
@@ -1,167 +0,0 @@
# OpenList 转码协议 v1(自研)
> 用于 OpenList Master 与远程 FFmpeg 转码 Worker 之间的通信。
> 协议本身、所有依赖、推荐编码 (H.264/AV1) 均为开源免费,可商用。
## 总览
```
Player ──► Master(/api/transcode/play) ──► Scheduler.Submit
Worker ──► Master(/api/transcode/worker/register) # 启动一次
Worker ──► Master(/api/transcode/worker/heartbeat) # 周期 10s
Worker ──► Master(/api/transcode/worker/claim) # 长轮询拉任务
Worker ──► Master(/api/transcode/worker/segment) # 流式回推切片
Worker ──► Master(/api/transcode/worker/job/finish) # 上报完成
Player ──► Master(/tc/{job}/{token}/master.m3u8) # 拉取播放列表
Player ──► Master(/tc/{job}/{token}/{profile}/seg-N.ts) # 拉取切片
```
## 通用约定
- 所有请求体均为 JSON。
- Worker 与 Master 间通过两类凭据鉴权:
- **shared_secret**(管理端 `transcode_worker_secret` 设置项):用于 register / heartbeat / claim
- **callback_token**(Master 在 job 中下发,一次性):用于 segment / job/finish
- Header 形式:`Authorization: Bearer <token>`
- 返回包统一使用 OpenList 通用响应:`{"code":200,"message":"success","data":{...}}`
## 端点详细
### POST /api/transcode/worker/register
> 鉴权:Bearer = shared_secret
请求:
```json
{
"name": "gpu-node-01",
"version": "1.0.0",
"capacity": 4,
"hwaccel": ["nvenc"],
"codecs_decode": ["h264","hevc","av1","vp9"],
"codecs_encode": ["h264","hevc"],
"max_resolution": "3840x2160",
"tags": ["linux","amd64"]
}
```
响应:
```json
{
"code":200,"message":"success",
"data": {
"worker_id":"wk_xxx",
"heartbeat_interval":10,
"claim_strategy":"pull",
"protocol_version":"v1"
}
}
```
### POST /api/transcode/worker/heartbeat
> 鉴权:Bearer = shared_secret
> 间隔:register 返回的 `heartbeat_interval`;服务端 30s 未收到则剔除 worker
请求:
```json
{ "worker_id":"wk_xxx", "load":0.4, "running":["job_a"], "free_slots":3 }
```
响应:
```json
{ "code":200,"message":"success","data":{"ok":true,"kick":false,"cancel":["job_x"]}}
```
- `kick=true`:服务端要求 Worker 主动退出
- `cancel`:需要 Worker 立刻终止的 job_ids(异步取消通道)
### POST /api/transcode/worker/claim
> 鉴权:Bearer = shared_secret
> 长轮询:当无任务时,服务端最多挂起 `wait` 秒(最大 30)
请求:
```json
{ "worker_id":"wk_xxx", "slots":1, "wait":20 }
```
响应:
```json
{ "code":200,"message":"success","data":{"jobs":[{
"id":"job_xxx",
"path":"/movies/x.mkv",
"source_url":"https://example.com/d/movies/x.mkv?sign=...",
"profiles":[{
"name":"1080p","video_codec":"h264","video_bitrate":"4000k",
"scale":"1920:-2","audio_codec":"aac","audio_bitrate":"160k",
"hwaccel":"nvenc"
}],
"output":{"format":"hls","segment_duration":6},
"callback_token":"tk_xxx",
"deadline":1731601234
}]}}
```
### PUT /api/transcode/worker/segment
> 鉴权:Bearer = callback_token
> Query 参数:
> - `job` 任务 ID
> - `profile` profile 名(如 `1080p`)
> - `seq` 切片序号(从 0 开始)
> - `duration` 该切片秒数
> - `final` 是否为最后一片(true/false)
>
> Body:原始 MPEG-TS 二进制(`Content-Type: video/mp2t`)
响应:`204 No Content`
### POST /api/transcode/worker/job/finish
> 鉴权:Bearer = callback_token
请求:
```json
{
"job_id":"job_xxx",
"status":"finished",
"error":"",
"stats":{"elapsed":312.4,"fps":120,"speed":2.1,"bytes_out":890123456}
}
```
status 取值:`finished` / `failed` / `cancelled`
## 播放端
### POST /api/fs/transcode/play
> 由前端播放器调用,用户登录态可见
> 请求:`{"path":"/movies/x.mkv"}`
>
> 响应(无需转码):
> `{"transcode":false,"reason":"size below threshold"}`
>
> 响应(已下发转码任务):
> `{"transcode":true,"job_id":"job_xx","master_url":"https://.../tc/.../master.m3u8","profile":"1080p"}`
### GET /tc/:job/:token/master.m3u8
返回多档位主 m3u8。
### GET /tc/:job/:token/:profile/playlist.m3u8
阻塞等待首切片就绪(最多 30s)后返回 HLS 播放列表,未结束时不带 `#EXT-X-ENDLIST`。
### GET /tc/:job/:token/:profile/seg-N.ts
返回 N 号切片;如果尚未生成,最多等待 60s。
## 启动远程 Worker
```bash
# 用 Master 配置中的 transcode_worker_secret 作为密钥
go build -o openlist-worker ./cmd/worker
./openlist-worker \
-master http://openlist.example.com:5244 \
-secret <transcode_worker_secret> \
-capacity 2 \
-hwaccel nvenc \
-ffmpeg /usr/bin/ffmpeg \
-workdir /tmp/openlist-worker
```
## License 说明
- 协议:自研,可自由使用
- FFmpeg:使用系统包提供的 LGPL 版本即可(含 libx264/libx265,libx264 是 GPL,建议在 Worker 镜像中遵循 GPL 公开/动态链接合规)
- 推荐编码:H.264 互联网分发免授权 / AV1 永久免版税
+4 -7
View File
@@ -5,7 +5,6 @@ import (
"strings"
"sync"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
@@ -69,10 +68,8 @@ func (d *Pan115) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
return nil, err
}
userAgent := args.Header.Get("User-Agent")
if userAgent == "" {
userAgent = base.UserAgent
}
downloadInfo, err := d.client.DownloadWithUA(file.(*FileObj).PickCode, userAgent)
downloadInfo, err := d.
DownloadWithUA(file.(*FileObj).PickCode, userAgent)
if err != nil {
return nil, err
}
@@ -255,8 +252,8 @@ func (d *Pan115) GetDetails(ctx context.Context) (*model.StorageDetails, error)
}
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: info.SpaceInfo.AllTotal.Size,
UsedSpace: info.SpaceInfo.AllUse.Size,
TotalSpace: uint64(info.SpaceInfo.AllTotal.Size),
FreeSpace: uint64(info.SpaceInfo.AllRemain.Size),
},
}, nil
}
-4
View File
@@ -22,10 +22,6 @@ func (f *FileObj) GetHash() utils.HashInfo {
return utils.NewHashInfo(utils.SHA1, f.Sha1)
}
func (f *FileObj) Thumb() string {
return f.ThumbURL
}
type UploadResult struct {
driver.BasicResp
Data struct {
+62 -7
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
@@ -19,12 +20,13 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
netutil "github.com/OpenListTeam/OpenList/v4/internal/net"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
cipher "github.com/SheltonZhu/115driver/pkg/crypto/ec115"
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
cipher "github.com/SheltonZhu/115driver/pkg/crypto/ec115"
crypto "github.com/SheltonZhu/115driver/pkg/crypto/m115"
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
"github.com/pkg/errors"
)
@@ -106,6 +108,60 @@ func (d *Pan115) getUA() string {
return fmt.Sprintf("Mozilla/5.0 115Browser/%s", appVer)
}
func (d *Pan115) DownloadWithUA(pickCode, ua string) (*driver115.DownloadInfo, error) {
key := crypto.GenerateKey()
result := driver115.DownloadResp{}
params, err := utils.Json.Marshal(map[string]string{"pick_code": pickCode})
if err != nil {
return nil, err
}
data := crypto.Encode(params, key)
bodyReader := strings.NewReader(url.Values{"data": []string{data}}.Encode())
reqUrl := fmt.Sprintf("%s?t=%s", driver115.AndroidApiDownloadGetUrl, driver115.Now().String())
req, _ := http.NewRequest(http.MethodPost, reqUrl, bodyReader)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", d.Cookie)
req.Header.Set("User-Agent", ua)
resp, err := d.client.Client.GetClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if err := utils.Json.Unmarshal(body, &result); err != nil {
return nil, err
}
if err = result.Err(string(body)); err != nil {
return nil, err
}
b, err := crypto.Decode(string(result.EncodedData), key)
if err != nil {
return nil, err
}
downloadInfo := struct {
Url string `json:"url"`
}{}
if err := utils.Json.Unmarshal(b, &downloadInfo); err != nil {
return nil, err
}
info := &driver115.DownloadInfo{}
info.PickCode = pickCode
info.Header = resp.Request.Header
info.Url.Url = downloadInfo.Url
return info, nil
}
func (c *Pan115) GenerateToken(fileID, preID, timeStamp, fileSize, signKey, signVal string) string {
userID := strconv.FormatInt(c.client.UserID, 10)
userIDMd5 := md5.Sum([]byte(userID))
@@ -223,7 +279,7 @@ func (c *Pan115) UploadByOSS(ctx context.Context, params *driver115.UploadOSSPar
if err != nil {
return nil, err
}
ossClient, err := netutil.NewOSSClient(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret)
ossClient, err := oss.New(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret)
if err != nil {
return nil, err
}
@@ -253,8 +309,7 @@ func (c *Pan115) UploadByOSS(ctx context.Context, params *driver115.UploadOSSPar
// UploadByMultipart upload by mutipart blocks
func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.UploadOSSParams, fileSize int64, s model.FileStreamer,
dirID string, up driver.UpdateProgress, opts ...driver115.UploadMultipartOption,
) (*UploadResult, error) {
dirID string, up driver.UpdateProgress, opts ...driver115.UploadMultipartOption) (*UploadResult, error) {
var (
chunks []oss.FileChunk
parts []oss.UploadPart
@@ -284,7 +339,7 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload
return nil, err
}
if ossClient, err = netutil.NewOSSClient(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret, oss.EnableMD5(true), oss.EnableCRC(true)); err != nil {
if ossClient, err = oss.New(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret, oss.EnableMD5(true), oss.EnableCRC(true)); err != nil {
return nil, err
}
+22 -71
View File
@@ -2,11 +2,8 @@ package _115_open
import (
"context"
"errors"
"fmt"
"net/http"
stdpath "path"
"slices"
"strconv"
"strings"
"time"
@@ -15,7 +12,6 @@ import (
"github.com/OpenListTeam/OpenList/v4/cmd/flags"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
@@ -27,9 +23,8 @@ import (
type Open115 struct {
model.Storage
Addition
client *sdk.Client
limiter *rate.Limiter
parentPath string
client *sdk.Client
limiter *rate.Limiter
}
func (d *Open115) Config() driver.Config {
@@ -58,34 +53,6 @@ func (d *Open115) Init(ctx context.Context) error {
if d.Addition.LimitRate > 0 {
d.limiter = rate.NewLimiter(rate.Limit(d.Addition.LimitRate), 1)
}
if d.PageSize <= 0 {
d.PageSize = 200
} else if d.PageSize > 1150 {
d.PageSize = 1150
}
// add parent path
d.parentPath = "/"
if d.GetRootId() != d.Config().DefaultRoot {
folderInfo, err := d.client.GetFolderInfo(ctx, d.GetRootId())
if err != nil {
return err
}
if folderInfo.FileID != d.Config().DefaultRoot {
d.parentPath = stdpath.Join(d.parentPath, folderInfo.FileName)
}
parentPaths := folderInfo.Paths
slices.Reverse(parentPaths)
for _, parentPathInfo := range parentPaths {
if parentPathInfo.FileID == d.Config().DefaultRoot {
d.parentPath = stdpath.Join("/", d.parentPath)
} else {
d.parentPath = stdpath.Join("/", parentPathInfo.FileName, d.parentPath)
}
}
}
return nil
}
@@ -102,7 +69,7 @@ func (d *Open115) Drop(ctx context.Context) error {
func (d *Open115) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
var res []model.Obj
pageSize := int64(d.PageSize)
pageSize := int64(200)
offset := int64(0)
for {
if err := d.WaitLimit(ctx); err != nil {
@@ -164,27 +131,6 @@ func (d *Open115) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
}, nil
}
func (d *Open115) Get(ctx context.Context, path string) (model.Obj, error) {
if err := d.WaitLimit(ctx); err != nil {
return nil, err
}
path = stdpath.Join(d.parentPath, path)
resp, err := d.client.GetFolderInfoByPath(ctx, path)
if err != nil {
if errors.Is(err, sdk.ErrObjectNotFound) {
return nil, errs.ObjectNotFound
}
return nil, err
}
return &Obj{
Fid: resp.FileID,
Fn: resp.FileName,
Fc: resp.FileCategory,
Sha1: resp.Sha1,
Pc: resp.PickCode,
}, nil
}
func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
if err := d.WaitLimit(ctx); err != nil {
return nil, err
@@ -204,15 +150,18 @@ func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName stri
}, nil
}
func (d *Open115) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
func (d *Open115) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
if err := d.WaitLimit(ctx); err != nil {
return err
return nil, err
}
_, err := d.client.Move(ctx, &sdk.MoveReq{
FileIDs: srcObj.GetID(),
ToCid: dstDir.GetID(),
})
return err
if err != nil {
return nil, err
}
return srcObj, nil
}
func (d *Open115) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
@@ -220,8 +169,8 @@ func (d *Open115) Rename(ctx context.Context, srcObj model.Obj, newName string)
return nil, err
}
_, err := d.client.UpdateFile(ctx, &sdk.UpdateFileReq{
FileID: srcObj.GetID(),
FileName: newName,
FileID: srcObj.GetID(),
FileNma: newName,
})
if err != nil {
return nil, err
@@ -229,21 +178,23 @@ func (d *Open115) Rename(ctx context.Context, srcObj model.Obj, newName string)
obj, ok := srcObj.(*Obj)
if ok {
obj.Fn = newName
return srcObj, nil
}
return nil, nil
return srcObj, nil
}
func (d *Open115) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
func (d *Open115) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
if err := d.WaitLimit(ctx); err != nil {
return err
return nil, err
}
_, err := d.client.Copy(ctx, &sdk.CopyReq{
PID: dstDir.GetID(),
FileID: srcObj.GetID(),
NoDupli: "1",
})
return err
if err != nil {
return nil, err
}
return srcObj, nil
}
func (d *Open115) Remove(ctx context.Context, obj model.Obj) error {
@@ -374,18 +325,18 @@ func (d *Open115) GetDetails(ctx context.Context) (*model.StorageDetails, error)
if err != nil {
return nil, err
}
total, err := ParseInt64(userInfo.RtSpaceInfo.AllTotal.Size)
total, err := userInfo.RtSpaceInfo.AllTotal.Size.Int64()
if err != nil {
return nil, err
}
used, err := ParseInt64(userInfo.RtSpaceInfo.AllUse.Size)
free, err := userInfo.RtSpaceInfo.AllRemain.Size.Int64()
if err != nil {
return nil, err
}
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
TotalSpace: uint64(total),
FreeSpace: uint64(free),
},
}, nil
}
-1
View File
@@ -12,7 +12,6 @@ type Addition struct {
OrderBy string `json:"order_by" type:"select" options:"file_name,file_size,user_utime,file_type"`
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc"`
LimitRate float64 `json:"limit_rate" type:"float" default:"1" help:"limit all api request rate ([limit]r/1s)"`
PageSize int64 `json:"page_size" type:"number" default:"200" help:"list api per page size of 115open driver"`
AccessToken string `json:"access_token" required:"true"`
RefreshToken string `json:"refresh_token" required:"true"`
}
+1 -1
View File
@@ -3,9 +3,9 @@ package _115_open
import (
"time"
sdk "github.com/OpenListTeam/115-sdk-go"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
sdk "github.com/OpenListTeam/115-sdk-go"
)
type Obj sdk.GetFilesResp_File
+4 -5
View File
@@ -9,7 +9,6 @@ import (
sdk "github.com/OpenListTeam/115-sdk-go"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
netutil "github.com/OpenListTeam/OpenList/v4/internal/net"
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
@@ -37,7 +36,7 @@ func calPartSize(fileSize int64) int64 {
}
func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error {
ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
ossClient, err := oss.New(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
if err != nil {
return err
}
@@ -71,7 +70,7 @@ func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp
// }
func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer, up driver.UpdateProgress, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error {
ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
ossClient, err := oss.New(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
if err != nil {
return err
}
@@ -108,16 +107,16 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
if err != nil {
return err
}
rateLimitedRd := driver.NewLimitedUploadStream(ctx, rd)
err = retry.Do(func() error {
rd.Seek(0, io.SeekStart)
part, err := bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), partSize, int(i))
part, err := bucket.UploadPart(imur, rateLimitedRd, partSize, int(i))
if err != nil {
return err
}
parts[i-1] = part
return nil
},
retry.Context(ctx),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Second))
+1 -13
View File
@@ -1,15 +1,3 @@
package _115_open
import "encoding/json"
func ParseInt64(v json.Number) (int64, error) {
i, err := v.Int64()
if err == nil {
return i, nil
}
f, e1 := v.Float64()
if e1 == nil {
return int64(f), nil
}
return int64(0), err
}
// do others that not defined in Driver interface
+5 -25
View File
@@ -2,9 +2,7 @@ package _115_share
import (
"context"
"net/http"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
@@ -51,16 +49,9 @@ func (d *Pan115Share) List(ctx context.Context, dir model.Obj, args model.ListAr
if err := d.WaitLimit(ctx); err != nil {
return nil, err
}
var ua string
// TODO: will use user agent from header
// if args.Header != nil {
// ua = args.Header.Get("User-Agent")
// }
if ua == "" {
ua = base.UserAgentNT
}
files := make([]driver115.ShareFile, 0)
fileResp, err := d.client.GetShareSnapWithUA(ua, d.ShareCode, d.ReceiveCode, dir.GetID(), driver115.QueryLimit(int(d.PageSize)))
fileResp, err := d.client.GetShareSnap(d.ShareCode, d.ReceiveCode, dir.GetID(), driver115.QueryLimit(int(d.PageSize)))
if err != nil {
return nil, err
}
@@ -86,23 +77,12 @@ func (d *Pan115Share) Link(ctx context.Context, file model.Obj, args model.LinkA
if err := d.WaitLimit(ctx); err != nil {
return nil, err
}
var ua string
if args.Header != nil {
ua = args.Header.Get("User-Agent")
}
if ua == "" {
ua = base.UserAgent
}
downloadInfo, err := d.client.DownloadByShareCodeWithUA(ua, d.ShareCode, d.ReceiveCode, file.GetID())
downloadInfo, err := d.client.DownloadByShareCode(d.ShareCode, d.ReceiveCode, file.GetID())
if err != nil {
return nil, err
}
header := http.Header{}
header.Set("User-Agent", ua)
return &model.Link{
URL: downloadInfo.URL.URL,
Header: header,
}, nil
return &model.Link{URL: downloadInfo.URL.URL}, nil
}
func (d *Pan115Share) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
+3 -8
View File
@@ -5,7 +5,6 @@ import (
"strconv"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
driver115 "github.com/SheltonZhu/115driver/pkg/driver"
@@ -21,7 +20,6 @@ type FileObj struct {
FileName string
isDir bool
FileID string
ThumbURL string
}
func (f *FileObj) CreateTime() time.Time {
@@ -56,10 +54,6 @@ func (f *FileObj) GetPath() string {
return ""
}
func (f *FileObj) Thumb() string {
return f.ThumbURL
}
func transFunc(sf driver115.ShareFile) (model.Obj, error) {
timeInt, err := strconv.ParseInt(sf.UpdateTime, 10, 64)
if err != nil {
@@ -80,14 +74,15 @@ func transFunc(sf driver115.ShareFile) (model.Obj, error) {
FileName: string(sf.FileName),
isDir: isDir,
FileID: fileID,
ThumbURL: sf.ThumbURL,
}, nil
}
var UserAgent = driver115.UA115Browser
func (d *Pan115Share) login() error {
var err error
opts := []driver115.Option{
driver115.UA(base.UserAgentNT),
driver115.UA(UserAgent),
}
d.client = driver115.New(opts...)
if _, err := d.client.GetShareSnap(d.ShareCode, d.ReceiveCode, ""); err != nil {
+4 -8
View File
@@ -41,9 +41,7 @@ func (d *Pan123) GetAddition() driver.Additional {
}
func (d *Pan123) Init(ctx context.Context) error {
_, err := d.Request(UserInfo, http.MethodGet, func(req *resty.Request) {
req.SetHeader("platform", "web")
}, nil)
_, err := d.Request(UserInfo, http.MethodGet, nil, nil)
return err
}
@@ -98,7 +96,7 @@ func (d *Pan123) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
}
log.Debug("download url: ", u_)
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://yun.123pan.com/").Get(u_)
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://www.123pan.com/").Get(u_)
if err != nil {
return nil, err
}
@@ -260,11 +258,9 @@ func (d *Pan123) GetDetails(ctx context.Context) (*model.StorageDetails, error)
if err != nil {
return nil, err
}
total := userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp,
UsedSpace: userInfo.Data.SpaceUsed,
},
DiskUsage: driver.DiskUsageFromUsedAndTotal(userInfo.Data.SpaceUsed, total),
}, nil
}
+1 -4
View File
@@ -12,15 +12,13 @@ type Addition struct {
//OrderBy string `json:"order_by" type:"select" options:"file_id,file_name,size,update_at" default:"file_name"`
//OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
AccessToken string
UploadThread int `json:"UploadThread" type:"number" default:"3" help:"the threads of upload"`
Platform string `json:"platform" type:"string" default:"web" help:"the platform header value, sent with API requests"`
UploadThread int `json:"UploadThread" type:"number" default:"3" help:"the threads of upload"`
}
var config = driver.Config{
Name: "123Pan",
DefaultRoot: "0",
LocalSort: true,
PreferProxy: true,
}
func init() {
@@ -29,7 +27,6 @@ func init() {
return &Pan123{
Addition: Addition{
UploadThread: 3,
Platform: "web",
},
}
})
+3 -48
View File
@@ -127,54 +127,9 @@ type UserInfoResp struct {
Data struct {
Uid int64 `json:"UID"`
Nickname string `json:"Nickname"`
SpaceUsed int64 `json:"SpaceUsed"`
SpacePermanent int64 `json:"SpacePermanent"`
SpaceTemp int64 `json:"SpaceTemp"`
SpaceUsed uint64 `json:"SpaceUsed"`
SpacePermanent uint64 `json:"SpacePermanent"`
SpaceTemp uint64 `json:"SpaceTemp"`
FileCount int `json:"FileCount"`
} `json:"data"`
}
type offlineResolveResp struct {
Data struct {
List []struct {
Result int `json:"result"`
ID int64 `json:"id"`
ErrCode int `json:"err_code"`
ErrMsg string `json:"err_msg"`
Files []struct {
ID int64 `json:"id"`
} `json:"files"`
} `json:"list"`
} `json:"data"`
}
type offlineSubmitResp struct {
Data struct {
TaskList []struct {
TaskID int64 `json:"task_id"`
Result int `json:"result"`
} `json:"task_list"`
} `json:"data"`
}
type offlineTaskListResp struct {
Data struct {
HasRun bool `json:"has_run"`
List []offlineTask `json:"list"`
Total int `json:"total"`
} `json:"data"`
}
type offlineTask struct {
TaskID int64 `json:"task_id"`
Name string `json:"name"`
Status int `json:"status"`
Size int64 `json:"size"`
ThirdTask string `json:"third_task_id"`
Downloaded int64 `json:"downloaded"`
Progress float64 `json:"progress"`
UploadIDR int64 `json:"upload_idr"`
UploadName string `json:"upload_name"`
Type string `json:"type"`
Speed int64 `json:"speed"`
}
+16 -7
View File
@@ -125,18 +125,27 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
curSize = lastChunkSize
}
var reader io.ReadSeeker
var rateLimitedRd io.Reader
threadG.GoWithLifecycle(errgroup.Lifecycle{
Before: func(ctx context.Context) (err error) {
reader, err = ss.GetSectionReader(offset, curSize)
return
Before: func(ctx context.Context) error {
if reader == nil {
var err error
reader, err = ss.GetSectionReader(offset, curSize)
if err != nil {
return err
}
rateLimitedRd = driver.NewLimitedUploadStream(ctx, reader)
}
return nil
},
Do: func(ctx context.Context) (err error) {
Do: func(ctx context.Context) error {
reader.Seek(0, io.SeekStart)
uploadUrl := s3PreSignedUrls.Data.PreSignedUrls[strconv.Itoa(cur)]
if uploadUrl == "" {
return fmt.Errorf("upload url is empty, s3PreSignedUrls: %+v", s3PreSignedUrls)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadUrl, driver.NewLimitedUploadStream(ctx, reader))
reader.Seek(0, io.SeekStart)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadUrl, rateLimitedRd)
if err != nil {
return err
}
@@ -148,7 +157,7 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
}
defer res.Body.Close()
if res.StatusCode == http.StatusForbidden {
_, err, _ = singleflight.AnyGroup.Do(fmt.Sprintf("Pan123.newUpload_%p", threadG), func() (any, error) {
singleflight.AnyGroup.Do(fmt.Sprintf("Pan123.newUpload_%p", threadG), func() (any, error) {
newS3PreSignedUrls, err := getS3UploadUrl(ctx, upReq, cur, end)
if err != nil {
return nil, err
@@ -168,7 +177,7 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
}
return fmt.Errorf("upload s3 chunk %d failed, status code: %d, body: %s", cur, res.StatusCode, body)
}
progress := 100 * float64(threadG.Success()+1) / float64(chunkCount+1)
progress := 10.0 + 85.0*float64(threadG.Success())/float64(chunkCount)
up(progress)
return nil
},
+8 -125
View File
@@ -14,7 +14,6 @@ import (
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/go-resty/resty/v2"
jsoniter "github.com/json-iterator/go"
@@ -24,9 +23,9 @@ import (
// do others that not defined in Driver interface
const (
Api = "https://yun.123pan.com/api"
AApi = "https://yun.123pan.com/a/api"
BApi = "https://yun.123pan.com/b/api"
Api = "https://www.123pan.com/api"
AApi = "https://www.123pan.com/a/api"
BApi = "https://www.123pan.com/b/api"
LoginApi = "https://login.123pan.com/api"
MainApi = BApi
SignIn = LoginApi + "/user/sign_in"
@@ -44,16 +43,9 @@ const (
S3Auth = MainApi + "/file/s3_upload_object/auth"
UploadCompleteV2 = MainApi + "/file/upload_complete/v2"
S3Complete = MainApi + "/file/s3_complete_multipart_upload"
OfflineResolve = MainApi + "/v2/offline_download/task/resolve"
OfflineSubmit = MainApi + "/v2/offline_download/task/submit"
OfflineTaskList = MainApi + "/offline_download/task/list"
OfflineTaskDelete = MainApi + "/offline_download/task/delete"
// AuthKeySalt = "8-8D$sL8gPjom7bk#cY"
)
var ErrOfflineTaskNotFound = errors.New("offline task not found")
func signPath(path string, os string, version string) (k string, v string) {
table := []byte{'a', 'd', 'e', 'f', 'g', 'h', 'l', 'm', 'y', 'i', 'j', 'n', 'o', 'p', 'k', 'q', 'r', 's', 't', 'u', 'b', 'c', 'v', 'w', 's', 'z'}
random := fmt.Sprintf("%.f", math.Round(1e7*rand.Float64()))
@@ -169,8 +161,8 @@ func (d *Pan123) login() error {
}
res, err := base.RestyClient.R().
SetHeaders(map[string]string{
"origin": "https://yun.123pan.com",
"referer": "https://yun.123pan.com/",
"origin": "https://www.123pan.com",
"referer": "https://www.123pan.com/",
"user-agent": "Dart/2.19(dart:io)-openlist",
"platform": "web",
"app-version": "3",
@@ -207,11 +199,11 @@ func (d *Pan123) Request(url string, method string, callback base.ReqCallback, r
do:
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"origin": "https://yun.123pan.com",
"referer": "https://yun.123pan.com/",
"origin": "https://www.123pan.com",
"referer": "https://www.123pan.com/",
"authorization": "Bearer " + d.AccessToken,
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) openlist-client",
"platform": d.Platform,
"platform": "web",
"app-version": "3",
//"user-agent": base.UserAgent,
})
@@ -246,115 +238,6 @@ do:
return body, nil
}
func (d *Pan123) OfflineDownload(ctx context.Context, uri string, dstDir model.Obj) (int64, error) {
var resolveResp offlineResolveResp
_, err := d.Request(OfflineResolve, http.MethodPost, func(req *resty.Request) {
req.SetContext(ctx).SetBody(base.Json{
"urls": uri,
})
}, &resolveResp)
if err != nil {
return 0, err
}
if len(resolveResp.Data.List) == 0 {
return 0, fmt.Errorf("offline resolve failed: empty response")
}
if resolveResp.Data.List[0].Result != 0 {
msg := resolveResp.Data.List[0].ErrMsg
if msg == "" {
msg = "offline resolve failed"
}
return 0, fmt.Errorf("%s", msg)
}
resourceID := resolveResp.Data.List[0].ID
if resourceID == 0 {
return 0, fmt.Errorf("offline resolve failed: empty resource id")
}
selectFileIDs := make([]int64, 0, len(resolveResp.Data.List[0].Files))
for _, f := range resolveResp.Data.List[0].Files {
if f.ID > 0 {
selectFileIDs = append(selectFileIDs, f.ID)
}
}
if len(selectFileIDs) == 0 {
return 0, fmt.Errorf("offline resolve failed: empty file list")
}
uploadDir, err := strconv.ParseInt(dstDir.GetID(), 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid destination dir id: %s", dstDir.GetID())
}
var submitResp offlineSubmitResp
_, err = d.Request(OfflineSubmit, http.MethodPost, func(req *resty.Request) {
req.SetContext(ctx).SetBody(base.Json{
"resource_list": []base.Json{
{
"resource_id": resourceID,
"select_file_id": selectFileIDs,
},
},
"upload_dir": uploadDir,
})
}, &submitResp)
if err != nil {
return 0, err
}
if len(submitResp.Data.TaskList) == 0 {
return 0, fmt.Errorf("offline submit failed: empty task list")
}
if submitResp.Data.TaskList[0].Result != 0 {
return 0, fmt.Errorf("offline submit failed")
}
if submitResp.Data.TaskList[0].TaskID == 0 {
return 0, fmt.Errorf("offline submit failed: empty task id")
}
return submitResp.Data.TaskList[0].TaskID, nil
}
func (d *Pan123) GetOfflineTask(ctx context.Context, taskID int64) (*offlineTask, error) {
if taskID == 0 {
return nil, fmt.Errorf("invalid task id")
}
page := 1
pageSize := 100
statusArr := []int{0, 1, 2, 3}
for {
var listResp offlineTaskListResp
_, err := d.Request(OfflineTaskList, http.MethodPost, func(req *resty.Request) {
req.SetContext(ctx).SetBody(base.Json{
"current_page": page,
"page_size": pageSize,
"status_arr": statusArr,
})
}, &listResp)
if err != nil {
return nil, err
}
for i := range listResp.Data.List {
if listResp.Data.List[i].TaskID == taskID {
return &listResp.Data.List[i], nil
}
}
if len(listResp.Data.List) == 0 || page*pageSize >= listResp.Data.Total {
break
}
page++
}
return nil, ErrOfflineTaskNotFound
}
func (d *Pan123) DeleteOfflineTasks(ctx context.Context, taskIDs []int64) error {
if len(taskIDs) == 0 {
return nil
}
_, err := d.Request(OfflineTaskDelete, http.MethodPost, func(req *resty.Request) {
req.SetContext(ctx).SetBody(base.Json{
"task_ids": taskIDs,
})
}, nil)
return err
}
func (d *Pan123) getFiles(ctx context.Context, parentId string, name string) ([]File, error) {
page := 1
total := 0
-4
View File
@@ -39,10 +39,6 @@ func (d *Pan123Link) Drop(ctx context.Context) error {
return nil
}
func (Addition) GetRootPath() string {
return "/"
}
func (d *Pan123Link) Get(ctx context.Context, path string) (model.Obj, error) {
node := GetNodeFromRootByPath(d.root, path)
return nodeToObj(node, path)
+4 -37
View File
@@ -18,7 +18,6 @@ type Open123 struct {
model.Storage
Addition
UID uint64
tm *tokenManager
}
func (d *Open123) Config() driver.Config {
@@ -34,24 +33,6 @@ func (d *Open123) Init(ctx context.Context) error {
d.UploadThread = 3
}
if (d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0) || (d.ClientID != "" && d.ClientSecret != "") {
// proactive refresh by renewapi or client credentials
d.AccessToken = ""
d.tm = &tokenManager{}
} else {
// 避免个人 token 刷新产生的多个登录,被动刷新
// 默认过期时间90天,jwt exp 不可靠
d.tm = &tokenManager{
// accessToken: d.AccessToken,
expiredAt: time.Now().Add(90 * 24 * time.Hour),
}
}
_, err := d.getAccessToken(false)
if err != nil {
return fmt.Errorf("init get access token error: %w", err)
}
return nil
}
@@ -181,22 +162,6 @@ func (d *Open123) Put(ctx context.Context, dstDir model.Obj, file model.FileStre
if err != nil {
return nil, fmt.Errorf("parse parentFileID error: %v", err)
}
// 尝试 SHA1 秒传
sha1Hash := file.GetHash().GetHash(utils.SHA1)
if len(sha1Hash) == utils.SHA1.Width {
resp, err := d.sha1Reuse(parentFileId, file.GetName(), sha1Hash, file.GetSize(), 2)
if err == nil && resp.Data.Reuse {
return File{
FileName: file.GetName(),
Size: file.GetSize(),
FileId: resp.Data.FileID,
Type: 2,
SHA1: sha1Hash,
}, nil
}
}
// etag 文件md5
etag := file.GetHash().GetHash(utils.MD5)
if len(etag) < utils.MD5.Width {
@@ -254,10 +219,12 @@ func (d *Open123) GetDetails(ctx context.Context) (*model.StorageDetails, error)
if err != nil {
return nil, err
}
total := userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp
free := total - userInfo.Data.SpaceUsed
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: userInfo.Data.SpacePermanent + userInfo.Data.SpaceTemp,
UsedSpace: userInfo.Data.SpaceUsed,
TotalSpace: total,
FreeSpace: free,
},
}, nil
}
+4 -9
View File
@@ -6,20 +6,16 @@ import (
)
type Addition struct {
// refresh_token方式的AccessToken 【对个人开发者暂未开放】
RefreshToken string `json:"RefreshToken" required:"false"`
// 通过 https://www.123pan.com/developer 申请
ClientID string `json:"ClientID" required:"false"`
ClientSecret string `json:"ClientSecret" required:"false"`
// 直接写入AccessToken, AccessToken有过期时间,不建议直接填写
// 直接写入AccessToken
AccessToken string `json:"AccessToken" required:"false"`
// refresh_token方式的AccessToken 【对个人开发者暂未开放】
RefreshToken string `json:"RefreshToken" required:"false"`
// 使用在线API
UseOnlineAPI bool `json:"use_online_api" default:"true"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/123cloud/renewapi"`
// 用户名+密码方式登录的AccessToken可以兼容
//Username string `json:"username" required:"false"`
//Password string `json:"password" required:"false"`
@@ -39,7 +35,6 @@ var config = driver.Config{
Name: "123 Open",
DefaultRoot: "0",
LocalSort: true,
PreferProxy: true,
}
func init() {
-129
View File
@@ -1,129 +0,0 @@
package _123_open
import (
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
var (
AccessToken = "https://open-api.123pan.com/api/v1/access_token"
)
func expiresInToExpiredAt(expiresIn int64) (time.Time, error) {
if expiresIn <= 0 {
return time.Time{}, errors.New("invalid expires_in from official API")
}
return time.Now().UTC().Add(time.Duration(expiresIn) * time.Second), nil
}
type tokenManager struct {
// accessToken string
expiredAt time.Time
mu sync.Mutex
blockRefresh bool
}
func (d *Open123) getAccessToken(forceRefresh bool) (string, error) {
tm := d.tm
tm.mu.Lock()
defer tm.mu.Unlock()
if tm.blockRefresh {
return "", errors.New("Authentication expired")
}
if !forceRefresh && d.AccessToken != "" && time.Now().Before(tm.expiredAt.Add(-5*time.Minute)) {
return d.AccessToken, nil
}
if err := d.flushAccessToken(); err != nil {
// token expired and failed to refresh, block further refresh attempts
tm.blockRefresh = true
return "", err
}
return d.AccessToken, nil
}
func (d *Open123) flushAccessToken() error {
// Official app renewapi response contains access_token, refresh_token and expires_in.
if d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0 {
var resp RefreshTokenResp
_, err := base.RestyClient.R().
SetResult(&resp).
SetQueryParams(map[string]string{
"refresh_ui": d.RefreshToken,
"server_use": "true",
"driver_txt": "123cloud_oa",
}).
Get(d.APIAddress)
if err != nil {
return err
}
if resp.AccessToken == "" || resp.RefreshToken == "" {
errMessage := resp.ErrorDescription
if errMessage == "" {
errMessage = resp.Text
}
if errMessage == "" {
errMessage = resp.Message
}
if errMessage == "" {
errMessage = resp.Error
}
if errMessage != "" {
return fmt.Errorf("failed to refresh token: %s", errMessage)
}
return fmt.Errorf("empty access_token or refresh_token returned from official API")
}
expiredAt, err := expiresInToExpiredAt(resp.ExpiresIn)
if err != nil {
return err
}
d.AccessToken = resp.AccessToken
d.RefreshToken = resp.RefreshToken
d.tm.expiredAt = expiredAt
op.MustSaveDriverStorage(d)
d.tm.blockRefresh = false
return nil
}
// Developer API response contains code/message/data(accessToken, expiredAt).
if d.ClientID != "" && d.ClientSecret != "" {
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"platform": "open_platform",
"Content-Type": "application/json",
})
var resp AccessTokenResp
req.SetBody(base.Json{
"clientID": d.ClientID,
"clientSecret": d.ClientSecret,
})
req.SetResult(&resp)
_, err := req.Execute(http.MethodPost, AccessToken)
if err != nil {
return err
}
if resp.Code != 0 {
return fmt.Errorf("get access token failed: %s", resp.Message)
}
if resp.Data.AccessToken == "" || resp.Data.ExpiredAt == "" {
return errors.New("invalid token payload from developer API")
}
expiredAt, err := time.Parse(time.RFC3339, resp.Data.ExpiredAt)
if err != nil {
return fmt.Errorf("parse expire time failed: %w", err)
}
d.AccessToken = resp.Data.AccessToken
d.tm.expiredAt = expiredAt.UTC()
op.MustSaveDriverStorage(d)
d.tm.blockRefresh = false
return nil
}
return errors.New("no valid authentication method available")
}
+8 -23
View File
@@ -58,13 +58,9 @@ type File struct {
Category int `json:"category"`
Status int `json:"status"`
Trashed int `json:"trashed"`
SHA1 string
}
func (f File) GetHash() utils.HashInfo {
if len(f.SHA1) == utils.SHA1.Width && len(f.Etag) != utils.MD5.Width {
return utils.NewHashInfo(utils.SHA1, f.SHA1)
}
return utils.NewHashInfo(utils.MD5, f.Etag)
}
@@ -125,14 +121,11 @@ type AccessTokenResp struct {
}
type RefreshTokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
Code int `json:"code"`
Message string `json:"message"`
ErrorDescription string `json:"error_description"`
Error string `json:"error"`
Text string `json:"text"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type UserInfoResp struct {
@@ -144,9 +137,9 @@ type UserInfoResp struct {
// HeadImage string `json:"headImage"`
// Passport string `json:"passport"`
// Mail string `json:"mail"`
SpaceUsed int64 `json:"spaceUsed"`
SpacePermanent int64 `json:"spacePermanent"`
SpaceTemp int64 `json:"spaceTemp"`
SpaceUsed uint64 `json:"spaceUsed"`
SpacePermanent uint64 `json:"spacePermanent"`
SpaceTemp uint64 `json:"spaceTemp"`
// SpaceTempExpr int64 `json:"spaceTempExpr"`
// Vip bool `json:"vip"`
// DirectTraffic int64 `json:"directTraffic"`
@@ -197,14 +190,6 @@ type UploadCompleteResp struct {
} `json:"data"`
}
type SHA1ReuseResp struct {
BaseResp
Data struct {
FileID int64 `json:"fileID"`
Reuse bool `json:"reuse"`
} `json:"data"`
}
type OfflineDownloadResp struct {
BaseResp
Data struct {
+19 -37
View File
@@ -73,20 +73,25 @@ func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createRes
// 表单
b := bytes.NewBuffer(make([]byte, 0, 2048))
threadG.GoWithLifecycle(errgroup.Lifecycle{
Before: func(ctx context.Context) (err error) {
reader, err = ss.GetSectionReader(offset, size)
return
},
Do: func(ctx context.Context) (err error) {
reader.Seek(0, io.SeekStart)
if sliceMD5 == "" {
// 把耗时的计算放在这里,避免阻塞其他协程
Before: func(ctx context.Context) error {
if reader == nil {
var err error
// 每个分片一个reader
reader, err = ss.GetSectionReader(offset, size)
if err != nil {
return err
}
// 计算当前分片的MD5
sliceMD5, err = utils.HashReader(utils.MD5, reader)
if err != nil {
return err
}
reader.Seek(0, io.SeekStart)
}
return nil
},
Do: func(ctx context.Context) error {
// 重置分片reader位置,因为HashReader、上一次失败已经读取到分片EOF
reader.Seek(0, io.SeekStart)
b.Reset()
w := multipart.NewWriter(b)
@@ -116,10 +121,6 @@ func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createRes
head := bytes.NewReader(b.Bytes()[:headSize])
tail := bytes.NewReader(b.Bytes()[headSize:])
rateLimitedRd = driver.NewLimitedUploadStream(ctx, io.MultiReader(head, reader, tail))
token, err := d.getAccessToken(false)
if err != nil {
return err
}
// 创建请求并设置header
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadDomain+"/upload/v2/file/slice", rateLimitedRd)
if err != nil {
@@ -127,7 +128,7 @@ func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createRes
}
// 设置请求头
req.Header.Add("Authorization", "Bearer "+token)
req.Header.Add("Authorization", "Bearer "+d.AccessToken)
req.Header.Add("Content-Type", w.FormDataContentType())
req.Header.Add("Platform", "open_platform")
@@ -139,13 +140,12 @@ func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createRes
if res.StatusCode != 200 {
return fmt.Errorf("slice %d upload failed, status code: %d", partNumber, res.StatusCode)
}
b.Reset()
_, err = b.ReadFrom(res.Body)
var resp BaseResp
respBody, err := io.ReadAll(res.Body)
if err != nil {
return err
}
var resp BaseResp
err = json.Unmarshal(b.Bytes(), &resp)
err = json.Unmarshal(respBody, &resp)
if err != nil {
return err
}
@@ -153,7 +153,7 @@ func (d *Open123) Upload(ctx context.Context, file model.FileStreamer, createRes
return fmt.Errorf("slice %d upload failed: %s", partNumber, resp.Message)
}
progress := 100 * float64(threadG.Success()+1) / float64(uploadNums+1)
progress := 10.0 + 85.0*float64(threadG.Success())/float64(uploadNums)
up(progress)
return nil
},
@@ -183,21 +183,3 @@ func (d *Open123) complete(preuploadID string) (*UploadCompleteResp, error) {
}
return &resp, nil
}
// SHA1 秒传
func (d *Open123) sha1Reuse(parentFileID int64, filename string, sha1Hash string, size int64, duplicate int) (*SHA1ReuseResp, error) {
var resp SHA1ReuseResp
_, err := d.Request(UploadSHA1Reuse, http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"parentFileID": parentFileID,
"filename": filename,
"sha1": strings.ToLower(sha1Hash),
"size": size,
"duplicate": duplicate,
})
}, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
+54 -19
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/go-resty/resty/v2"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
@@ -21,31 +22,29 @@ import (
var ( // 不同情况下获取的AccessTokenQPS限制不同 如下模块化易于拓展
Api = "https://open-api.123pan.com"
UserInfo = InitApiInfo(Api+"/api/v1/user/info", 1)
FileList = InitApiInfo(Api+"/api/v2/file/list", 3)
DownloadInfo = InitApiInfo(Api+"/api/v1/file/download_info", 5)
DirectLink = InitApiInfo(Api+"/api/v1/direct-link/url", 5)
Mkdir = InitApiInfo(Api+"/upload/v1/file/mkdir", 2)
Move = InitApiInfo(Api+"/api/v1/file/move", 1)
Rename = InitApiInfo(Api+"/api/v1/file/name", 1)
Trash = InitApiInfo(Api+"/api/v1/file/trash", 2)
UploadCreate = InitApiInfo(Api+"/upload/v2/file/create", 2)
UploadComplete = InitApiInfo(Api+"/upload/v2/file/upload_complete", 0)
UploadSHA1Reuse = InitApiInfo(Api+"/upload/v2/file/sha1_reuse", 2)
AccessToken = InitApiInfo(Api+"/api/v1/access_token", 1)
RefreshToken = InitApiInfo(Api+"/api/v1/oauth2/access_token", 1)
UserInfo = InitApiInfo(Api+"/api/v1/user/info", 1)
FileList = InitApiInfo(Api+"/api/v2/file/list", 3)
DownloadInfo = InitApiInfo(Api+"/api/v1/file/download_info", 5)
DirectLink = InitApiInfo(Api+"/api/v1/direct-link/url", 5)
Mkdir = InitApiInfo(Api+"/upload/v1/file/mkdir", 2)
Move = InitApiInfo(Api+"/api/v1/file/move", 1)
Rename = InitApiInfo(Api+"/api/v1/file/name", 1)
Trash = InitApiInfo(Api+"/api/v1/file/trash", 2)
UploadCreate = InitApiInfo(Api+"/upload/v2/file/create", 2)
UploadComplete = InitApiInfo(Api+"/upload/v2/file/upload_complete", 0)
OfflineDownload = InitApiInfo(Api+"/api/v1/offline/download", 1)
OfflineDownloadProcess = InitApiInfo(Api+"/api/v1/offline/download/process", 5)
)
func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
retryToken := true
for {
token, err := d.getAccessToken(false)
if err != nil {
return nil, err
}
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"authorization": "Bearer " + token,
"authorization": "Bearer " + d.AccessToken,
"platform": "open_platform",
"Content-Type": "application/json",
})
@@ -75,9 +74,9 @@ func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCall
if baseResp.Code == 0 {
return body, nil
} else if baseResp.Code == 401 {
// 强制刷新Token, 有小概率会 race condition 导致多次刷新Token,但不影响正确运行
if _, err := d.getAccessToken(true); err != nil {
} else if baseResp.Code == 401 && retryToken {
retryToken = false
if err := d.flushAccessToken(); err != nil {
return nil, err
}
} else if baseResp.Code == 429 {
@@ -89,6 +88,42 @@ func (d *Open123) Request(apiInfo *ApiInfo, method string, callback base.ReqCall
}
}
func (d *Open123) flushAccessToken() error {
if d.ClientID != "" {
if d.RefreshToken != "" {
var resp RefreshTokenResp
_, err := d.Request(RefreshToken, http.MethodPost, func(req *resty.Request) {
req.SetQueryParam("client_id", d.ClientID)
if d.ClientSecret != "" {
req.SetQueryParam("client_secret", d.ClientSecret)
}
req.SetQueryParam("grant_type", "refresh_token")
req.SetQueryParam("refresh_token", d.RefreshToken)
}, &resp)
if err != nil {
return err
}
d.AccessToken = resp.AccessToken
d.RefreshToken = resp.RefreshToken
op.MustSaveDriverStorage(d)
} else if d.ClientSecret != "" {
var resp AccessTokenResp
_, err := d.Request(AccessToken, http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"clientID": d.ClientID,
"clientSecret": d.ClientSecret,
})
}, &resp)
if err != nil {
return err
}
d.AccessToken = resp.Data.AccessToken
op.MustSaveDriverStorage(d)
}
}
return nil
}
func (d *Open123) SignURL(originURL, privateKey string, uid uint64, validDuration time.Duration) (newURL string, err error) {
// 生成Unix时间戳
ts := time.Now().Add(validDuration).Unix()
+1 -1
View File
@@ -101,7 +101,7 @@ func (d *Pan123Share) Link(ctx context.Context, file model.Obj, args model.LinkA
}
log.Debug("download url: ", u_)
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://yun.123pan.com/").Get(u_)
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://www.123pan.com/").Get(u_)
if err != nil {
return nil, err
}
-1
View File
@@ -19,7 +19,6 @@ var config = driver.Config{
LocalSort: true,
NoUpload: true,
DefaultRoot: "0",
PreferProxy: true,
}
func init() {
+5 -5
View File
@@ -20,9 +20,9 @@ import (
)
const (
Api = "https://yun.123pan.com/api"
AApi = "https://yun.123pan.com/a/api"
BApi = "https://yun.123pan.com/b/api"
Api = "https://www.123pan.com/api"
AApi = "https://www.123pan.com/a/api"
BApi = "https://www.123pan.com/b/api"
MainApi = BApi
FileList = MainApi + "/share/get"
DownloadInfo = MainApi + "/share/download/info"
@@ -58,8 +58,8 @@ func (d *Pan123Share) request(url string, method string, callback base.ReqCallba
}
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"origin": "https://yun.123pan.com",
"referer": "https://yun.123pan.com/",
"origin": "https://www.123pan.com",
"referer": "https://www.123pan.com/",
"authorization": "Bearer " + d.AccessToken,
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) openlist-client",
"platform": "web",
+48 -129
View File
@@ -14,7 +14,6 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/cron"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
@@ -29,7 +28,6 @@ type Yun139 struct {
Account string
ref *Yun139
PersonalCloudHost string
RootPath string
}
func (d *Yun139) Config() driver.Config {
@@ -43,16 +41,7 @@ func (d *Yun139) GetAddition() driver.Additional {
func (d *Yun139) Init(ctx context.Context) error {
if d.ref == nil {
if len(d.Authorization) == 0 {
if d.Username != "" && d.Password != "" {
log.Infof("139yun: authorization is empty, trying to login with password.")
newAuth, err := d.loginWithPassword()
log.Debugf("newAuth: Ok: %s", newAuth)
if err != nil {
return fmt.Errorf("login with password failed: %w", err)
}
} else {
return fmt.Errorf("authorization is empty and username/password is not provided")
}
return fmt.Errorf("authorization is empty")
}
err := d.refreshToken()
if err != nil {
@@ -103,22 +92,7 @@ func (d *Yun139) Init(ctx context.Context) error {
if len(d.Addition.RootFolderID) == 0 {
d.RootFolderID = d.CloudID
}
_, err := d.groupGetFiles(d.RootFolderID)
if err != nil {
return err
}
case MetaFamily:
if len(d.Addition.RootFolderID) == 0 {
// Attempt to obtain data.path as the root via a query and persist it.
if root, err := d.getFamilyRootPath(d.CloudID); err == nil && root != "" {
d.RootFolderID = root
op.MustSaveDriverStorage(d)
}
}
_, err := d.familyGetFiles(d.RootFolderID)
if err != nil {
return err
}
default:
return errs.NotImplement
}
@@ -305,42 +279,6 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
return nil, err
}
return srcObj, nil
case MetaFamily:
pathname := "/isbo/openApi/createBatchOprTask"
var contentList []string
var catalogList []string
if srcObj.IsDir() {
catalogList = append(catalogList, path.Join(srcObj.GetPath(), srcObj.GetID()))
} else {
contentList = append(contentList, path.Join(srcObj.GetPath(), srcObj.GetID()))
}
body := base.Json{
"catalogList": catalogList,
"accountInfo": base.Json{
"accountName": d.getAccount(),
"accountType": "1",
},
"contentList": contentList,
"destCatalogID": dstDir.GetID(),
"destGroupID": d.CloudID,
"destPath": path.Join(dstDir.GetPath(), dstDir.GetID()),
"destType": 0,
"srcGroupID": d.CloudID,
"srcType": 0,
"taskType": 3,
}
var resp CreateBatchOprTaskResp
_, err := d.isboPost(pathname, body, &resp)
if err != nil {
return nil, err
}
log.Debugf("[139] Move MetaFamily CreateBatchOprTaskResp.Result.ResultCode: %s", resp.Result.ResultCode)
if resp.Result.ResultCode != "0" {
return nil, fmt.Errorf("failed to move in family cloud: %s", resp.Result.ResultDesc)
}
return srcObj, nil
default:
return nil, errs.NotImplement
}
@@ -415,27 +353,19 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
var data base.Json
var pathname string
if srcObj.IsDir() {
pathname = "/modifyCloudDocV2"
data = base.Json{
"catalogType": 3,
"cloudID": d.CloudID,
"commonAccountInfo": base.Json{
"account": d.getAccount(),
"accountType": "1",
},
"docLibName": newName,
"docLibraryID": srcObj.GetID(),
"path": path.Join(srcObj.GetPath(), srcObj.GetID()),
}
var resp ModifyCloudDocV2Resp
_, err = d.andAlbumRequest(pathname, data, &resp)
if err != nil {
return err
}
if resp.Result.ResultCode != "0" {
return fmt.Errorf("failed to rename family folder: %s", resp.Result.ResultDesc)
}
return nil
// 网页接口不支持重命名家庭云文件夹
// data = base.Json{
// "catalogType": 3,
// "catalogID": srcObj.GetID(),
// "catalogName": newName,
// "commonAccountInfo": base.Json{
// "account": d.getAccount(),
// "accountType": 1,
// },
// "path": srcObj.GetPath(),
// }
// pathname = "/orchestration/familyCloud-rebuild/photoContent/v1.0/modifyCatalogInfo"
return errs.NotImplement
} else {
data = base.Json{
"contentID": srcObj.GetID(),
@@ -491,33 +421,6 @@ func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
}
pathname := "/orchestration/personalCloud/batchOprTask/v1.0/createBatchOprTask"
_, err = d.post(pathname, data, nil)
case MetaGroup:
err = d.handleMetaGroupCopy(ctx, srcObj, dstDir)
case MetaFamily:
pathname := "/copyContentCatalog"
var sourceContentIDs []string
var sourceCatalogIDs []string
if srcObj.IsDir() {
sourceCatalogIDs = append(sourceCatalogIDs, srcObj.GetID())
} else {
sourceContentIDs = append(sourceContentIDs, srcObj.GetID())
}
body := base.Json{
"commonAccountInfo": base.Json{
"accountType": "1",
"accountUserId": d.ref.UserDomainID,
},
"destCatalogID": dstDir.GetID(),
"destCloudID": d.CloudID,
"sourceCatalogIDs": sourceCatalogIDs,
"sourceCloudID": d.CloudID,
"sourceContentIDs": sourceContentIDs,
}
var resp base.Json // Assuming a generic JSON response for success/failure
_, err = d.andAlbumRequest(pathname, body, &resp)
// For now, we assume no error means success.
default:
err = errs.NotImplement
}
@@ -777,8 +680,6 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
return nil
case MetaPersonal:
fallthrough
case MetaGroup:
fallthrough
case MetaFamily:
// 处理冲突
// 获取文件列表
@@ -826,17 +727,12 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
},
}
pathname := "/orchestration/personalCloud/uploadAndDownload/v1.0/pcUploadFileRequest"
if d.isFamily() || d.Addition.Type == MetaGroup {
uploadPath := path.Join(dstDir.GetPath(), dstDir.GetID())
// if dstDir is root folder
if dstDir.GetID() == d.RootFolderID {
uploadPath = d.RootPath
}
if d.isFamily() {
data = d.newJson(base.Json{
"fileCount": 1,
"manualRename": 2,
"operation": 0,
"path": uploadPath,
"path": path.Join(dstDir.GetPath(), dstDir.GetID()),
"seqNo": random.String(32), // 序列号不能为空
"totalSize": reportSize,
"uploadContentList": []base.Json{{
@@ -848,7 +744,6 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
pathname = "/orchestration/familyCloud-rebuild/content/v1.0/getFileUploadURL"
}
var resp UploadResp
log.Debugf("[139] upload request body: %+v", data)
_, err = d.post(pathname, data, &resp)
if err != nil {
return err
@@ -944,18 +839,42 @@ func (d *Yun139) GetDetails(ctx context.Context) (*model.StorageDetails, error)
if d.UserDomainID == "" {
return nil, errs.NotImplement
}
detail, err := d.getDiskQuotaDetail(ctx)
if err != nil {
return nil, err
var total, free uint64
if d.isFamily() {
diskInfo, err := d.getFamilyDiskInfo(ctx)
if err != nil {
return nil, err
}
totalMb, err := strconv.ParseUint(diskInfo.Data.DiskSize, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed convert disk size into integer: %+v", err)
}
usedMb, err := strconv.ParseUint(diskInfo.Data.UsedSize, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed convert used size into integer: %+v", err)
}
total = totalMb * 1024 * 1024
free = total - (usedMb * 1024 * 1024)
} else {
diskInfo, err := d.getPersonalDiskInfo(ctx)
if err != nil {
return nil, err
}
totalMb, err := strconv.ParseUint(diskInfo.Data.DiskSize, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed convert disk size into integer: %+v", err)
}
freeMb, err := strconv.ParseUint(diskInfo.Data.FreeDiskSize, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed convert free size into integer: %+v", err)
}
total = totalMb * 1024 * 1024
free = freeMb * 1024 * 1024
}
total := detail.Data.DiskSize * utils.MB
used := (detail.Data.DiskSize - detail.Data.FreeDiskSize) * utils.MB
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
FreeSpace: free,
},
}, nil
}
-3
View File
@@ -8,9 +8,6 @@ import (
type Addition struct {
//Account string `json:"account" required:"true"`
Authorization string `json:"authorization" type:"text" required:"true"`
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true" secret:"true"`
MailCookies string `json:"mail_cookies" required:"true" type:"text" help:"Cookies from mail.139.com used for login authentication."`
driver.RootID
Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"`
CloudID string `json:"cloud_id"`
+10 -65
View File
@@ -313,74 +313,19 @@ type RefreshTokenResp struct {
Desc string `xml:"desc"`
}
type DiskQuotaDetail struct {
type PersonalDiskInfoResp struct {
BaseResp
Data struct {
FreeDiskSize int64 `json:"freeDiskSize"`
DiskSize int64 `json:"diskSize"`
// QuotaList []struct {
// DriveType int `json:"driveType"`
// DriveName string `json:"driveName"`
// UsedSize int64 `json:"usedSize"`
// } `json:"quotaList"`
FreeDiskSize string `json:"freeDiskSize"`
DiskSize string `json:"diskSize"`
IsInfinitePicStorage *bool `json:"isInfinitePicStorage"`
} `json:"data"`
}
type AndAlbumUploadResp struct {
Result struct {
ResultCode string `json:"resultCode"`
ResultDesc string `json:"resultDesc"`
} `json:"result"`
UploadResult struct {
UploadTaskID string `json:"uploadTaskID"`
RedirectionURL string `json:"redirectionUrl"`
NewContentIDList []struct {
ContentID string `json:"contentID"`
ContentName string `json:"contentName"`
} `json:"newContentIDList"`
} `json:"uploadResult"`
}
type ModifyCloudDocV2Req struct {
CatalogType int `json:"catalogType"`
CloudID string `json:"cloudID"`
CommonAccountInfo struct {
Account string `json:"account"`
AccountType string `json:"accountType"`
} `json:"commonAccountInfo"`
DocLibName string `json:"docLibName"`
DocLibraryID string `json:"docLibraryID"`
Path string `json:"path"`
}
type ModifyCloudDocV2Resp struct {
Result struct {
ResultCode string `json:"resultCode"`
ResultDesc string `json:"resultDesc"`
} `json:"result"`
}
type CreateBatchOprTaskReq struct {
CatalogList []string `json:"catalogList"`
CommonAccountInfo struct {
Account string `json:"account"`
AccountType string `json:"accountType"`
} `json:"commonAccountInfo"`
ContentList []string `json:"contentList"`
DestCatalogID string `json:"destCatalogID"`
DestGroupID string `json:"destGroupID"`
DestPath string `json:"destPath"`
DestType int `json:"destType"`
SourceCatalogType int `json:"sourceCatalogType"`
SourceCloudID string `json:"sourceCloudID"`
SourceType int `json:"sourceType"`
TaskType int `json:"taskType"`
}
type CreateBatchOprTaskResp struct {
Result struct {
ResultCode string `json:"resultCode"`
ResultDesc string `json:"resultDesc"`
} `json:"result"`
TaskID string `json:"taskID"`
type FamilyDiskInfoResp struct {
BaseResp
Data struct {
UsedSize string `json:"usedSize"`
DiskSize string `json:"diskSize"`
} `json:"data"`
}
+26 -698
View File
@@ -1,21 +1,14 @@
package _139
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
crypto_rand "crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"regexp"
"sort"
"strconv"
"strings"
@@ -32,11 +25,6 @@ import (
log "github.com/sirupsen/logrus"
)
const (
KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d" // 第一层 AES 解密密钥
KEY_HEX_2 = "7150714477323633586746674c337538" // 第二层 AES 解密密钥
)
// do others that not defined in Driver interface
func (d *Yun139) isFamily() bool {
return d.Type == "family"
@@ -108,16 +96,12 @@ func (d *Yun139) refreshToken() error {
SetBody(reqBody).
SetResult(&resp).
Post(url)
if err != nil || resp.Return != "0" {
log.Warnf("139yun: failed to refresh token with old token: %v, desc: %s. trying to login with password.", err, resp.Desc)
newAuth, loginErr := d.loginWithPassword()
log.Debugf("newAuth: Ok: %s", newAuth)
if loginErr != nil {
return fmt.Errorf("failed to login with password after refresh failed: %w", loginErr)
}
return nil
if err != nil {
return err
}
if resp.Return != "0" {
return fmt.Errorf("failed to refresh token: %s", resp.Desc)
}
d.Authorization = base64.StdEncoding.EncodeToString([]byte(splits[0] + ":" + splits[1] + ":" + resp.Token))
op.MustSaveDriverStorage(d)
return nil
@@ -162,29 +146,10 @@ func (d *Yun139) request(url string, method string, callback base.ReqCallback, r
var e BaseResp
req.SetResult(&e)
log.Debugf("[139] request: %s %s, body: %s", method, url, string(body))
res, err := req.Execute(method, url)
if err != nil {
log.Debugf("[139] request error: %v", err)
return nil, err
}
log.Debugf("[139] response body: %s", res.String())
log.Debugln(res.String())
if !e.Success {
// Always try to unmarshal to the specific response type first if 'resp' is provided.
if resp != nil {
err = utils.Json.Unmarshal(res.Body(), resp)
if err != nil {
log.Debugf("[139] failed to unmarshal response to specific type: %v", err)
return nil, err // Return unmarshal error
}
if createBatchOprTaskResp, ok := resp.(*CreateBatchOprTaskResp); ok {
log.Debugf("[139] CreateBatchOprTaskResp.Result.ResultCode: %s", createBatchOprTaskResp.Result.ResultCode)
if createBatchOprTaskResp.Result.ResultCode == "0" {
goto SUCCESS_PROCESS
}
}
}
return nil, errors.New(e.Message) // Fallback to original error if not handled
return nil, errors.New(e.Message)
}
if resp != nil {
err = utils.Json.Unmarshal(res.Body(), resp)
@@ -192,7 +157,6 @@ func (d *Yun139) request(url string, method string, callback base.ReqCallback, r
return nil, err
}
}
SUCCESS_PROCESS:
return res.Body(), nil
}
@@ -204,7 +168,9 @@ func (d *Yun139) requestRoute(data interface{}, resp interface{}) ([]byte, error
callback := func(req *resty.Request) {
req.SetBody(data)
}
callback(req)
if callback != nil {
callback(req)
}
body, err := utils.Json.Marshal(req.Body)
if err != nil {
return nil, err
@@ -345,9 +311,6 @@ func (d *Yun139) familyGetFiles(catalogID string) ([]model.Obj, error) {
return nil, err
}
path := resp.Data.Path
if catalogID == d.RootFolderID {
d.RootPath = path
}
for _, catalog := range resp.Data.CloudCatalogList {
f := model.Object{
ID: catalog.CatalogID,
@@ -403,9 +366,6 @@ func (d *Yun139) groupGetFiles(catalogID string) ([]model.Obj, error) {
return nil, err
}
path := resp.Data.GetGroupContentResult.ParentCatalogID
if catalogID == d.RootFolderID {
d.RootPath = path
}
for _, catalog := range resp.Data.GetGroupContentResult.CatalogList {
f := model.Object{
ID: catalog.CatalogID,
@@ -534,13 +494,11 @@ func (d *Yun139) personalRequest(pathname string, method string, callback base.R
var e BaseResp
req.SetResult(&e)
log.Debugf("[139] personal request: %s %s, body: %s", method, url, string(body))
res, err := req.Execute(method, url)
if err != nil {
log.Debugf("[139] personal request error: %v", err)
return nil, err
}
log.Debugf("[139] personal response body: %s", res.String())
log.Debugln(res.String())
if !e.Success {
return nil, errors.New(e.Message)
}
@@ -559,13 +517,6 @@ func (d *Yun139) personalPost(pathname string, data interface{}, resp interface{
}, resp)
}
func (d *Yun139) isboPost(pathname string, data interface{}, resp interface{}) ([]byte, error) {
url := "https://group.yun.139.com/hcy/mutual/adapter" + pathname
return d.request(url, http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, resp)
}
func getPersonalTime(t string) time.Time {
stamp, err := time.ParseInLocation("2006-01-02T15:04:05.999-07:00", t, utils.CNLoc)
if err != nil {
@@ -652,12 +603,10 @@ func (d *Yun139) personalGetLink(fileId string) (string, error) {
}
cdnUrl := jsoniter.Get(res, "data", "cdnUrl").ToString()
if cdnUrl != "" {
cdnSwitch := jsoniter.Get(res, "data", "cdnSwitch").ToBool()
if cdnSwitch {
return cdnUrl, nil
}
return cdnUrl, nil
} else {
return jsoniter.Get(res, "data", "url").ToString(), nil
}
return jsoniter.Get(res, "data", "url").ToString(), nil
}
func (d *Yun139) getAuthorization() string {
@@ -725,12 +674,12 @@ func (d *Yun139) uploadPersonalParts(ctx context.Context, partInfos []PartInfo,
return nil
}
func (d *Yun139) getDiskQuotaDetail(ctx context.Context) (*DiskQuotaDetail, error) {
func (d *Yun139) getPersonalDiskInfo(ctx context.Context) (*PersonalDiskInfoResp, error) {
data := map[string]interface{}{
"userDomainId": d.UserDomainID,
}
var resp DiskQuotaDetail
_, err := d.request("https://user-njs.yun.139.com/user/disk/quota/detail", http.MethodPost, func(req *resty.Request) {
var resp PersonalDiskInfoResp
_, err := d.request("https://user-njs.yun.139.com/user/disk/getPersonalDiskInfo", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
req.SetContext(ctx)
}, &resp)
@@ -740,638 +689,17 @@ func (d *Yun139) getDiskQuotaDetail(ctx context.Context) (*DiskQuotaDetail, erro
return &resp, nil
}
func (d *Yun139) step1_password_login() (string, error) {
log.Debugf("--- 执行步骤 1: 登录 API ---")
loginURL := "https://mail.10086.cn/Login/Login.ashx"
// 密码 SHA1 哈希
hashedPassword := sha1Hash(fmt.Sprintf("fetion.com.cn:%s", d.Password))
log.Debugf("DEBUG: 原始密码: %s", d.Password)
log.Debugf("DEBUG: SHA1 输入: fetion.com.cn:%s", d.Password)
log.Debugf("DEBUG: 生成的 Password 哈希: %s", hashedPassword)
cguid := strconv.FormatInt(time.Now().UnixMilli(), 10) // 随机生成 cguid
loginHeaders := map[string]string{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6,en-GB;q=0.5",
"cache-control": "max-age=0",
"content-type": "application/x-www-form-urlencoded",
"dnt": "1",
"origin": "https://mail.10086.cn",
"priority": "u=0, i",
"referer": fmt.Sprintf("https://mail.10086.cn/default.html?&s=1&v=0&u=%s&m=1&ec=S001&resource=indexLogin&clientid=1003&auto=on&cguid=%s&mtime=45", base64.StdEncoding.EncodeToString([]byte(d.Username)), cguid),
"sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"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,
func (d *Yun139) getFamilyDiskInfo(ctx context.Context) (*FamilyDiskInfoResp, error) {
data := map[string]interface{}{
"userDomainId": d.UserDomainID,
}
loginData := url.Values{}
loginData.Set("UserName", d.Username)
loginData.Set("passOld", "")
loginData.Set("auto", "on")
loginData.Set("Password", hashedPassword)
loginData.Set("webIndexPagePwdLogin", "1")
loginData.Set("pwdType", "1")
loginData.Set("clientId", "1003")
loginData.Set("authType", "2")
log.Debugf("DEBUG: 登录请求 URL: %s", loginURL)
log.Debugf("DEBUG: 登录请求 Headers: %+v", loginHeaders)
log.Debugf("DEBUG: 登录请求 Body: %s", loginData.Encode())
// 设置客户端不跟随重定向
client := base.RestyClient.SetRedirectPolicy(resty.NoRedirectPolicy())
res, err := client.R().
SetHeaders(loginHeaders).
SetFormDataFromValues(loginData).
Post(loginURL)
if err != nil {
// 如果是重定向错误,则不作为失败处理,因为我们禁止了自动重定向
if res != nil && res.StatusCode() >= 300 && res.StatusCode() < 400 {
log.Debugf("DEBUG: 登录响应 Status Code: %d (Redirect)", res.StatusCode())
} else {
return "", fmt.Errorf("step1 login request failed: %w", err)
}
} else {
log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode())
}
// 恢复客户端的默认重定向策略,以免影响后续请求
base.RestyClient.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
log.Debugf("DEBUG: 登录响应 Headers: %+v", res.Header())
var sid, extractedCguid string
// 从 Location 头部提取 sid 和 cguid
locationHeader := res.Header().Get("Location")
if locationHeader != "" {
sidMatch := regexp.MustCompile(`sid=([^&]+)`).FindStringSubmatch(locationHeader)
cguidMatch := regexp.MustCompile(`cguid=([^&]+)`).FindStringSubmatch(locationHeader)
if len(sidMatch) > 1 {
sid = sidMatch[1]
log.Debugf("DEBUG: 从 Location 提取到 sid: %s", sid)
}
if len(cguidMatch) > 1 {
extractedCguid = cguidMatch[1]
log.Debugf("DEBUG: 从 Location 提取到 cguid: %s", extractedCguid)
}
}
// 如果 Location 中没有,尝试从 Set-Cookie 中提取
if sid == "" || extractedCguid == "" {
setCookieHeaders := res.Header().Values("Set-Cookie")
for _, cookieStr := range setCookieHeaders {
ssoSidMatch := regexp.MustCompile(`Os_SSo_Sid=([^;]+)`).FindStringSubmatch(cookieStr)
cookieCguidMatch := regexp.MustCompile(`cguid=([^;]+)`).FindStringSubmatch(cookieStr)
if len(ssoSidMatch) > 1 && sid == "" {
sid = ssoSidMatch[1]
log.Debugf("DEBUG: 从 Set-Cookie 提取到 sid: %s", sid)
}
if len(cookieCguidMatch) > 1 && extractedCguid == "" {
extractedCguid = cookieCguidMatch[1]
log.Debugf("DEBUG: 从 Set-Cookie 提取到 cguid: %s", extractedCguid)
}
}
}
if sid == "" || extractedCguid == "" {
return "", errors.New("failed to extract sid or cguid from login response")
}
// 提取并记录 cookies
loginUrlObj, _ := url.Parse(loginURL)
cookies := base.RestyClient.GetClient().Jar.Cookies(loginUrlObj)
var cookieStrings []string
for _, cookie := range cookies {
cookieStrings = append(cookieStrings, cookie.Name+"="+cookie.Value)
}
cookieStr := strings.Join(cookieStrings, "; ")
log.Debugf("DEBUG: 提取到的 Cookies: %s", cookieStr)
d.MailCookies = cookieStr
return sid, nil
}
func (d *Yun139) step2_get_single_token(sid string) (string, error) {
log.Debugf("\n--- 执行步骤 2: 换artifact API ---")
cguid := strconv.FormatInt(time.Now().UnixMilli(), 10)
exchangeArtifactURL := fmt.Sprintf("https://smsrebuild1.mail.10086.cn/setting/s?func=%s&sid=%s&cguid=%s", url.QueryEscape("umc:getArtifact"), sid, cguid)
// 从 MailCookies 中提取 RMKEY
var rmkey string
cookies := strings.Split(d.MailCookies, ";")
for _, cookie := range cookies {
cookie = strings.TrimSpace(cookie)
if strings.HasPrefix(cookie, "RMKEY=") {
rmkey = cookie
break
}
}
if rmkey == "" {
return "", errors.New("RMKEY not found in MailCookies")
}
exchangePassidHeaders := map[string]string{
"Host": "smsrebuild1.mail.10086.cn",
"Cookie": rmkey,
"Content-Type": "text/xml; charset=utf-8",
"Accept-Encoding": "gzip",
"User-Agent": "okhttp/4.12.0",
}
log.Debugf("DEBUG: 换passid 请求 URL: %s", exchangeArtifactURL)
log.Debugf("DEBUG: 换passid 请求 Headers: %+v", exchangePassidHeaders)
res, err := base.RestyClient.R().
SetHeaders(exchangePassidHeaders).
Post(exchangeArtifactURL)
if err != nil {
return "", fmt.Errorf("step2 exchange artifact request failed: %w", err)
}
log.Debugf("DEBUG: 换passid 响应 Status Code: %d", res.StatusCode())
log.Debugf("DEBUG: 换passid 响应 Headers: %+v", res.Header())
log.Debugf("DEBUG: 换passid 响应 Body: %s...", res.String()[:min(len(res.String()), 500)])
dycpwd := jsoniter.Get(res.Body(), "var", "artifact").ToString()
if dycpwd == "" {
return "", errors.New("failed to extract dycpwd from artifact exchange response")
}
log.Debugf("DEBUG: 提取到 dycpwd: %s", dycpwd)
return dycpwd, nil
}
// --- 辅助函数:加密/解密 ---
// sha1Hash 计算 SHA1 哈希值,返回十六进制字符串。
func sha1Hash(data string) string {
h := sha1.New()
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}
// pkcs7_pad PKCS7 填充
func pkcs7_pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
// pkcs7_unpad PKCS7 去填充
func pkcs7_unpad(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("pkcs7: data is empty")
}
unpadding := int(data[length-1])
if unpadding > length {
return nil, errors.New("pkcs7: invalid padding")
}
return data[:(length - unpadding)], nil
}
// aes_ecb_decrypt AES/ECB/Pkcs7 解密,输入为十六进制字符串。
func aes_ecb_decrypt(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
var resp FamilyDiskInfoResp
_, err := d.request("https://user-njs.yun.139.com/user/disk/getFamilyDiskInfo", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
req.SetContext(ctx)
}, &resp)
if err != nil {
return nil, err
}
if len(ciphertext)%block.BlockSize() != 0 {
return nil, errors.New("AES ECB decrypt: ciphertext is not a multiple of the block size")
}
decrypted := make([]byte, len(ciphertext))
blockSize := block.BlockSize()
for bs, be := 0, blockSize; bs < len(ciphertext); bs, be = bs+blockSize, be+blockSize {
block.Decrypt(decrypted[bs:be], ciphertext[bs:be])
}
return pkcs7_unpad(decrypted)
}
// 以下提供 camelCase 的 AES CBC 加解密,供文件中其它位置调用(并支持传入 IV)。
func aesCbcEncrypt(plaintext []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() {
return nil, fmt.Errorf("aesCbcEncrypt: iv length %d does not match block size %d", len(iv), block.BlockSize())
}
padded := pkcs7_pad(plaintext, block.BlockSize())
ciphertext := make([]byte, len(padded))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, padded)
return ciphertext, nil
}
func aesCbcDecrypt(ciphertext []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() {
return nil, fmt.Errorf("aesCbcDecrypt: iv length %d does not match block size %d", len(iv), block.BlockSize())
}
if len(ciphertext)%block.BlockSize() != 0 {
return nil, errors.New("aesCbcDecrypt: ciphertext is not a multiple of the block size")
}
decrypted := make([]byte, len(ciphertext))
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(decrypted, ciphertext)
return pkcs7_unpad(decrypted)
}
// sortedJsonStringify 对 JSON 对象进行排序并字符串化。
func sortedJsonStringify(obj interface{}) (string, error) {
if obj == nil {
return "null", nil
}
switch v := obj.(type) {
case string:
// 尝试解析为 JSON,如果成功则递归处理
var parsed interface{}
if err := jsoniter.Unmarshal([]byte(v), &parsed); err == nil {
return sortedJsonStringify(parsed)
}
// 如果不是 JSON 字符串,则直接返回 JSON 字符串化的结果
return jsoniter.MarshalToString(v)
case int, float64, bool:
return fmt.Sprintf("%v", v), nil
case []interface{}:
var items []string
for _, item := range v {
s, err := sortedJsonStringify(item)
if err != nil {
return "", err
}
items = append(items, s)
}
return fmt.Sprintf("[%s]", strings.Join(items, ",")), nil
case map[string]interface{}:
sortedKeys := make([]string, 0, len(v))
for key := range v {
sortedKeys = append(sortedKeys, key)
}
sort.Strings(sortedKeys)
var pairs []string
for _, key := range sortedKeys {
value := v[key]
s, err := sortedJsonStringify(value)
if err != nil {
return "", err
}
// Use jsoniter.MarshalToString for the key to ensure it's quoted correctly
keyStr, err := jsoniter.MarshalToString(key)
if err != nil {
return "", err
}
pairs = append(pairs, fmt.Sprintf("%s:%s", keyStr, s))
}
return fmt.Sprintf("{%s}", strings.Join(pairs, ",")), nil
default:
// Fallback for other types, e.g., numbers, booleans, or unhandled complex types
// Use jsoniter's default marshalling for these
return jsoniter.MarshalToString(v)
}
}
// yun139EncryptedRequest handles the common encrypted request/response flow.
func (d *Yun139) yun139EncryptedRequest(url string, body interface{}, headers map[string]string, aesKeyHex string, resp interface{}) ([]byte, error) {
// 1. Decode AES key
aesKey, err := hex.DecodeString(aesKeyHex)
if err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: failed to decode AES key: %w", err)
}
// 2. Marshal and sort the request body
sortedJson, err := sortedJsonStringify(body)
if err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: failed to marshal and sort body: %w", err)
}
log.Debugf("yun139EncryptedRequest: Request Body (plaintext): %s", sortedJson)
// 3. Encrypt the body using AES/CBC
iv := make([]byte, 16) // 16 bytes for AES-128
if _, err := crypto_rand.Read(iv); err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: failed to generate IV: %w", err)
}
encryptedBody, err := aesCbcEncrypt([]byte(sortedJson), aesKey, iv)
if err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: failed to encrypt body: %w", err)
}
payload := base64.StdEncoding.EncodeToString(append(iv, encryptedBody...))
// 4. Make the request
res, err := base.RestyClient.R().
SetHeaders(headers).
SetBody(payload).
Post(url)
if err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: http request failed: %w", err)
}
if res.StatusCode() != 200 {
return nil, fmt.Errorf("yun139EncryptedRequest: unexpected status code %d: %s", res.StatusCode(), res.String())
}
// 5. Decrypt the response
respBody := res.Body()
var decryptedBytes []byte
if len(respBody) > 0 && respBody[0] == '{' {
log.Warnf("yun139EncryptedRequest: received a plain JSON response, not an encrypted string. Body: %s", string(respBody))
decryptedBytes = respBody
} else {
decodedResp, err := base64.StdEncoding.DecodeString(string(respBody))
if err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: response base64 decode failed: %w. Body: '%s'", err, string(respBody))
}
if len(decodedResp) < 16 {
return nil, fmt.Errorf("yun139EncryptedRequest: decoded response is too short to be encrypted. Length: %d", len(decodedResp))
}
respIv := decodedResp[:16]
respCiphertext := decodedResp[16:]
decryptedBytes, err = aesCbcDecrypt(respCiphertext, aesKey, respIv)
if err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: response aes decrypt failed: %w", err)
}
}
log.Debugf("yun139EncryptedRequest: Response Body (decrypted): %s", string(decryptedBytes))
// 6. Unmarshal to the final response struct
if resp != nil {
err = utils.Json.Unmarshal(decryptedBytes, resp)
if err != nil {
return nil, fmt.Errorf("yun139EncryptedRequest: failed to unmarshal decrypted response: %w", err)
}
}
return decryptedBytes, nil
}
func (d *Yun139) step3_third_party_login(dycpwd string) (string, error) {
log.Debugf("\n--- 执行步骤 3: 单点登录 API ---")
ssoLoginURL := "https://user-njs.yun.139.com/user/thirdlogin"
// 构建原始请求体
ssoRequestBodyRaw := base.Json{
"clientkey_decrypt": "l3TryM&Q+X7@dzwk)qP",
"clienttype": "886",
"cpid": "507",
"dycpwd": dycpwd,
"extInfo": base.Json{"ifOpenAccount": "0"},
"loginMode": "0",
"msisdn": d.Username,
"pintype": "13",
"secinfo": strings.ToUpper(sha1Hash(fmt.Sprintf("fetion.com.cn:%s", dycpwd))),
"version": "20250901",
}
ssoLoginHeaders := map[string]string{
"hcy-cool-flag": "1",
"x-huawei-channelSrc": "10246600",
"x-sdk-channelSrc": "",
"x-MM-Source": "0",
"x-UserAgent": "android|23116PN5BC|android15|1.2.6|||1440x3200|10246600",
"x-DeviceInfo": "4|127.0.0.1|5|1.2.6|Xiaomi|23116PN5BC||02-00-00-00-00-00|android 15|1440x3200|android|||",
"Content-Type": "text/plain;charset=UTF-8",
"Host": "user-njs.yun.139.com",
"Accept-Encoding": "gzip",
"User-Agent": "okhttp/3.12.2",
}
// 使用通用加密请求函数
decryptedLayer1StrBytes, err := d.yun139EncryptedRequest(ssoLoginURL, ssoRequestBodyRaw, ssoLoginHeaders, KEY_HEX_1, nil)
if err != nil {
return "", fmt.Errorf("step3 encrypted request failed: %w", err)
}
hexInner := jsoniter.Get(decryptedLayer1StrBytes, "data").ToString()
if hexInner == "" {
return "", errors.New("missing data field in first layer decryption result")
}
log.Debugf("DEBUG: 第一层解密提取到 hex_inner: %s...", hexInner[:min(len(hexInner), 50)])
// 第二层解密
key2, err := hex.DecodeString(KEY_HEX_2)
if err != nil {
return "", fmt.Errorf("failed to decode KEY_HEX_2: %w", err)
}
hexInnerBytes, err := hex.DecodeString(hexInner)
if err != nil {
return "", fmt.Errorf("failed to decode hex_inner: %w", err)
}
finalJsonStrBytes, err := aes_ecb_decrypt(hexInnerBytes, key2)
if err != nil {
return "", fmt.Errorf("step3 response layer2 aes ecb decrypt failed: %w", err)
}
log.Debugf("DEBUG: 最终解密结果: %s", string(finalJsonStrBytes))
// 提取 authToken
authToken := jsoniter.Get(finalJsonStrBytes, "authToken").ToString()
if authToken == "" {
return "", errors.New("failed to extract authToken from final decryption result")
}
log.Debugf("DEBUG: 提取到 authToken: %s", authToken)
// 提取 account 和 userDomainId
account := jsoniter.Get(finalJsonStrBytes, "account").ToString()
userDomainId := jsoniter.Get(finalJsonStrBytes, "userDomainId").ToString()
if account == "" || userDomainId == "" {
return "", errors.New("failed to extract account or userDomainId from final decryption result")
}
d.UserDomainID = userDomainId
newAuthorization := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("pc:%s:%s", account, authToken)))
return newAuthorization, nil
}
func (d *Yun139) loginWithPassword() (string, error) {
if d.Username == "" || d.Password == "" || d.MailCookies == "" {
return "", errors.New("username, password or mail_cookies is empty")
}
passId, err := d.step1_password_login()
if err != nil {
return "", err
}
log.Infof("Step 1 success, passId: %s", passId)
token, err := d.step2_get_single_token(passId)
if err != nil {
return "", err
}
log.Infof("Step 2 success, token: %s", token)
newAuth, err := d.step3_third_party_login(token)
if err != nil {
return "", err
}
log.Infof("Step 3 success, new authorization generated.")
d.Authorization = newAuth // Ensure Authorization is also updated before saving
op.MustSaveDriverStorage(d)
return newAuth, nil
}
func (d *Yun139) andAlbumRequest(pathname string, body interface{}, resp interface{}) ([]byte, error) {
url := "https://group.yun.139.com/hcy/family/adapter/andAlbum/openApi" + pathname
headers := map[string]string{
"Host": "group.yun.139.com",
"authorization": "Basic " + d.getAuthorization(),
"x-svctype": "2",
"hcy-cool-flag": "1",
"api-version": "v2",
"x-huawei-channelsrc": "10246600",
"x-sdk-channelsrc": "",
"x-mm-source": "0",
"x-deviceinfo": "1|127.0.0.1|1|12.3.2|Xiaomi|23116PN5BC||02-00-00-00-00-00|android 15|1440x3200|android|zh||||032|0|", //重要参数
"content-type": "application/json; charset=utf-8",
"user-agent": "okhttp/4.11.0",
"accept-encoding": "gzip",
}
return d.yun139EncryptedRequest(url, body, headers, KEY_HEX_1, resp)
}
func (d *Yun139) handleMetaGroupCopy(ctx context.Context, srcObj, dstDir model.Obj) error {
pathname := "/copyContentCatalog"
var sourceContentIDs []string
var sourceCatalogIDs []string
if srcObj.IsDir() {
sourceCatalogIDs = append(sourceCatalogIDs, path.Join("root:/", srcObj.GetPath(), srcObj.GetID()))
} else {
sourceContentIDs = append(sourceContentIDs, path.Join("root:/", srcObj.GetPath(), srcObj.GetID()))
}
destCatalogID := path.Join("root:/", dstDir.GetPath(), dstDir.GetID())
log.Debugf("[139Yun Group Copy] srcObj ID: %s, srcObj Path: %s, dstDir ID: %s, dstDir Path: %s, destCatalogID: %s", srcObj.GetID(), srcObj.GetPath(), dstDir.GetID(), dstDir.GetPath(), destCatalogID)
body := base.Json{
"commonAccountInfo": base.Json{
"accountType": "1",
"accountUserId": d.UserDomainID,
},
"destCatalogID": destCatalogID,
"destCloudID": d.CloudID,
"sourceCatalogIDs": sourceCatalogIDs,
"sourceCloudID": d.CloudID,
"sourceContentIDs": sourceContentIDs,
}
var resp base.Json
_, err := d.andAlbumRequest(pathname, body, &resp)
return err
}
// getGroupRootByCloudID 查询 group 上层信息,优先返回 parentCatalogID,回退到 catalogList[0].path
func (d *Yun139) getGroupRootByCloudID(cloudID string) (string, error) {
pathname := "/orchestration/group-rebuild/catalog/v1.0/queryGroupContentList"
body := base.Json{
"groupID": cloudID,
"commonAccountInfo": base.Json{
"account": d.getAccount(),
"accountType": 1,
},
"pageInfo": base.Json{
"pageNum": 1,
"pageSize": 1,
},
}
var resp base.Json
_, err := d.post(pathname, body, &resp)
if err != nil {
return "", err
}
dataObj, _ := resp["data"].(map[string]interface{})
if dataObj == nil {
return "", fmt.Errorf("invalid group response data")
}
if gcr, ok := dataObj["getGroupContentResult"].(map[string]interface{}); ok {
if pid, ok := gcr["parentCatalogID"].(string); ok && pid != "" {
return pid, nil
}
if cl, ok := gcr["catalogList"].([]interface{}); ok && len(cl) > 0 {
if first, ok := cl[0].(map[string]interface{}); ok {
if p, ok := first["path"].(string); ok && p != "" {
return p, nil
}
}
}
}
return "", fmt.Errorf("no root found in group response")
}
// getFamilyRootPath 查询 family 的上层 path(data.path)
// 返回值已去除前缀 "root:/"(或 "root:"),直接返回纯 ID 或 path 部分,便于持久化为 RootFolderID。
func (d *Yun139) getFamilyRootPath(cloudID string) (string, error) {
// 使用 v1.2 接口(代码日志中已有该请求),pageSize 取 1 足够获取 path 字段
pathname := "/orchestration/familyCloud-rebuild/content/v1.2/queryContentList"
body := base.Json{
"catalogID": "",
"catalogType": 3,
"cloudID": cloudID,
"cloudType": 1,
"commonAccountInfo": base.Json{
"account": d.getAccount(),
"accountType": 1,
},
"contentSortType": 0,
"pageInfo": base.Json{
"pageNum": 1,
"pageSize": 1,
},
"sortDirection": 1,
}
var resp base.Json
_, err := d.post(pathname, body, &resp)
if err != nil {
return "", err
}
dataObj, _ := resp["data"].(map[string]interface{})
if dataObj == nil {
return "", fmt.Errorf("invalid family response data")
}
// helper to strip "root:/" or "root:" prefix
stripRoot := func(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "root:/")
s = strings.TrimPrefix(s, "root:")
return s
}
if p, ok := dataObj["path"].(string); ok && p != "" {
return stripRoot(p), nil
}
// 回退:有时 path 在 cloudCatalogList.catalogList 中
if cl, ok := dataObj["cloudCatalogList"].([]interface{}); ok && len(cl) > 0 {
if first, ok := cl[0].(map[string]interface{}); ok {
if p, ok := first["path"].(string); ok && p != "" {
return stripRoot(p), nil
}
}
}
return "", fmt.Errorf("no path found in family response")
return &resp, nil
}
+1 -1
View File
@@ -202,7 +202,7 @@ func (d *Cloud189) GetDetails(ctx context.Context) (*model.StorageDetails, error
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: capacityInfo.CloudCapacityInfo.TotalSize,
UsedSpace: capacityInfo.CloudCapacityInfo.UsedSize,
FreeSpace: capacityInfo.CloudCapacityInfo.FreeSize,
},
}, nil
}
-149
View File
@@ -1,149 +0,0 @@
package _189
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// GenerateTorrent 根据上传过程中收集的哈希信息生成包含 CAS 扩展的 torrent 文件
func GenerateTorrent(fileName string, fileSize int64, fileMD5 string, sliceMD5s []string, sliceSize int64, pieceHashes []byte) ([]byte, error) {
// 计算 sliceMD5
sliceMD5 := fileMD5
if len(sliceMD5s) > 1 {
joined := strings.Join(sliceMD5s, "\n")
sliceMD5 = strings.ToUpper(torrent.GetMD5Str(joined))
}
t := torrent.NewTorrent(fileName, fileSize, fileMD5)
t.Info.PieceLength = sliceSize
t.SetPieces(pieceHashes)
t.SetCASInfo(&torrent.CASInfo{
FileMD5: fileMD5,
SliceMD5: sliceMD5,
SliceMD5s: sliceMD5s,
SliceSize: sliceSize,
Cloud: "189",
})
return t.Encode()
}
// RapidUploadFromTorrent 从 torrent 文件中提取 CAS 信息进行秒传
func (d *Cloud189) RapidUploadFromTorrent(ctx context.Context, dstDir model.Obj, torrentData []byte) error {
// 解析 torrent
t, err := torrent.Decode(torrentData)
if err != nil {
return fmt.Errorf("解析 torrent 失败: %w", err)
}
// 检查是否包含 CAS 扩展信息
if !t.HasCASInfo() {
return fmt.Errorf("torrent 不包含 CAS 扩展信息,无法秒传")
}
cas := t.CAS
fileName := t.Info.Name
fileSize := t.GetTotalSize()
// 获取 sessionKey
sessionKey, err := d.getSessionKey()
if err != nil {
return err
}
d.sessionKey = sessionKey
// 初始化上传
res, err := d.uploadRequest("/person/initMultiUpload", map[string]string{
"parentFolderId": dstDir.GetID(),
"fileName": encode(fileName),
"fileSize": fmt.Sprint(fileSize),
"sliceSize": fmt.Sprint(cas.SliceSize),
"lazyCheck": "1",
}, nil)
if err != nil {
return fmt.Errorf("初始化上传失败: %w", err)
}
uploadFileId := utils.Json.Get(res, "data", "uploadFileId").ToString()
// 提交上传(使用 CAS 信息秒传)
_, err = d.uploadRequest("/person/commitMultiUploadFile", map[string]string{
"uploadFileId": uploadFileId,
"fileMd5": cas.FileMD5,
"sliceMd5": cas.SliceMD5,
"lazyCheck": "1",
"opertype": "3",
}, nil)
if err != nil {
return fmt.Errorf("秒传提交失败: %w", err)
}
return nil
}
// ComputeTorrentFromReader 从 io.Reader 计算并生成 torrent 文件
func ComputeTorrentFromReader(reader io.Reader, fileName string, fileSize int64, sliceSize int64) ([]byte, error) {
if sliceSize <= 0 {
sliceSize = torrent.DefaultPieceSize
}
hw := torrent.NewHashWriter(sliceSize, sliceSize)
buf := make([]byte, 32*1024)
for {
n, err := reader.Read(buf)
if n > 0 {
hw.Write(buf[:n])
}
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
}
hw.Finish()
fileMD5 := hw.GetFileMD5()
sliceMD5s := hw.GetSliceMD5s()
pieceHashes := hw.GetPieceHashes()
return GenerateTorrent(fileName, fileSize, fileMD5, sliceMD5s, sliceSize, pieceHashes)
}
// ComputePieceSHA1 计算单个分片的 SHA-1 哈希
func ComputePieceSHA1(data []byte) []byte {
h := sha1.Sum(data)
return h[:]
}
// ExtractCASFromTorrent 从 torrent 数据中提取 CAS 信息
func ExtractCASFromTorrent(torrentData []byte) (*torrent.CASInfo, string, int64, error) {
t, err := torrent.Decode(torrentData)
if err != nil {
return nil, "", 0, fmt.Errorf("解析 torrent 失败: %w", err)
}
if !t.HasCASInfo() {
return nil, "", 0, fmt.Errorf("torrent 不包含 CAS 扩展信息")
}
return t.CAS, t.Info.Name, t.GetTotalSize(), nil
}
// GetInfoHashHex 获取 torrent 的 info_hash(十六进制字符串)
func GetInfoHashHex(torrentData []byte) (string, error) {
t, err := torrent.Decode(torrentData)
if err != nil {
return "", err
}
return hex.EncodeToString(t.InfoHash), nil
}
+7 -7
View File
@@ -72,15 +72,15 @@ type CapacityResp struct {
ResMessage string `json:"res_message"`
Account string `json:"account"`
CloudCapacityInfo struct {
FreeSize int64 `json:"freeSize"`
MailUsedSize int64 `json:"mail189UsedSize"`
TotalSize int64 `json:"totalSize"`
UsedSize int64 `json:"usedSize"`
FreeSize uint64 `json:"freeSize"`
MailUsedSize uint64 `json:"mail189UsedSize"`
TotalSize uint64 `json:"totalSize"`
UsedSize uint64 `json:"usedSize"`
} `json:"cloudCapacityInfo"`
FamilyCapacityInfo struct {
FreeSize int64 `json:"freeSize"`
TotalSize int64 `json:"totalSize"`
UsedSize int64 `json:"usedSize"`
FreeSize uint64 `json:"freeSize"`
TotalSize uint64 `json:"totalSize"`
UsedSize uint64 `json:"usedSize"`
} `json:"familyCapacityInfo"`
TotalSize uint64 `json:"totalSize"`
}
+26 -174
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"crypto/md5"
sha1Pkg "crypto/sha1"
"encoding/base64"
"encoding/hex"
"errors"
@@ -19,8 +18,6 @@ import (
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
myrand "github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
"github.com/go-resty/resty/v2"
@@ -110,7 +107,7 @@ import (
// res, err = d.client.R().
// SetHeaders(map[string]string{
// "lt": lt,
// "User-Agent": base.UserAgentNT,
// "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36",
// "Referer": "https://open.e.189.cn/",
// "accept": "application/json;charset=UTF-8",
// }).SetFormData(map[string]string{
@@ -314,107 +311,48 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F
}
d.sessionKey = sessionKey
const DEFAULT int64 = 10485760
fileSize := file.GetSize()
count := int64(math.Ceil(float64(fileSize) / float64(DEFAULT)))
count := int64(math.Ceil(float64(file.GetSize()) / float64(DEFAULT)))
// 先计算文件完整MD5和分片MD5,用于秒传判断
fileMd5Hex := file.GetHash().GetHash(utils.MD5)
sliceMd5Hex := ""
md5s := make([]string, 0)
if len(fileMd5Hex) < utils.MD5.Width {
// 没有MD5,先缓存流并同时计算文件MD5和分片MD5
fileMd5Hash := md5.New()
sliceMd5Hash := md5.New()
var finish int64
cache, err := file.CacheFullAndWriter(nil, io.MultiWriter(fileMd5Hash, &sliceHashWriter{
hash: sliceMd5Hash,
md5s: &md5s,
sliceSize: DEFAULT,
finish: &finish,
fileSize: fileSize,
up: up,
ctx: ctx,
}))
if err != nil {
return err
}
// 处理最后一个分片的MD5
if finish%DEFAULT != 0 || finish == 0 {
md5s = append(md5s, strings.ToUpper(hex.EncodeToString(sliceMd5Hash.Sum(nil))))
}
fileMd5Hex = hex.EncodeToString(fileMd5Hash.Sum(nil))
// seek回起始位置,供后续上传使用
if _, err := cache.Seek(0, io.SeekStart); err != nil {
return err
}
}
// 计算sliceMd5
if fileSize > DEFAULT && len(md5s) > 0 {
sliceMd5Hex = utils.GetMD5EncodeStr(strings.Join(md5s, "\n"))
} else {
sliceMd5Hex = fileMd5Hex
}
// 带fileMd5调用initMultiUpload,支持秒传
initParams := map[string]string{
res, err := d.uploadRequest("/person/initMultiUpload", map[string]string{
"parentFolderId": dstDir.GetID(),
"fileName": encode(file.GetName()),
"fileSize": strconv.FormatInt(fileSize, 10),
"fileSize": strconv.FormatInt(file.GetSize(), 10),
"sliceSize": strconv.FormatInt(DEFAULT, 10),
"fileMd5": fileMd5Hex,
"sliceMd5": sliceMd5Hex,
}
res, err := d.uploadRequest("/person/initMultiUpload", initParams, nil)
"lazyCheck": "1",
}, nil)
if err != nil {
return err
}
uploadFileId := jsoniter.Get(res, "data", "uploadFileId").ToString()
fileDataExists := jsoniter.Get(res, "data", "fileDataExists").ToInt()
// 秒传成功,直接提交
if fileDataExists == 1 {
_, err = d.uploadRequest("/person/commitMultiUploadFile", map[string]string{
"uploadFileId": uploadFileId,
"fileMd5": fileMd5Hex,
"sliceMd5": sliceMd5Hex,
"lazyCheck": "1",
"opertype": "3",
}, nil)
return err
}
// 非秒传,需要上传分片
//_, err = d.uploadRequest("/person/getUploadedPartsInfo", map[string]string{
// "uploadFileId": uploadFileId,
//}, nil)
var finish int64 = 0
var i int64
var byteSize int64
// 额外计算 SHA-1 piece hash 用于生成 torrent
pieceSHA1Hashes := make([]byte, 0, int(count)*20)
md5s := make([]string, 0)
md5Sum := md5.New()
for i = 1; i <= count; i++ {
if utils.IsCanceled(ctx) {
return ctx.Err()
}
byteSize = fileSize - finish
byteSize = file.GetSize() - finish
if DEFAULT < byteSize {
byteSize = DEFAULT
}
// log.Debugf("%d,%d", byteSize, finish)
byteData := make([]byte, byteSize)
n, err := io.ReadFull(file, byteData)
// log.Debug(err, n)
if err != nil {
return err
}
finish += int64(n)
md5Bytes := getMd5(byteData)
md5Hex := hex.EncodeToString(md5Bytes)
md5Base64 := base64.StdEncoding.EncodeToString(md5Bytes)
// 计算 SHA-1 piece hash
sha1Hash := sha1Pkg.Sum(byteData)
pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Hash[:]...)
md5s = append(md5s, strings.ToUpper(md5Hex))
md5Sum.Write(byteData)
var resp UploadUrlsResp
res, err = d.uploadRequest("/person/getMultiUploadUrls", map[string]string{
"partInfo": fmt.Sprintf("%s-%s", strconv.FormatInt(i, 10), md5Base64),
@@ -441,58 +379,21 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F
}
log.Debugf("%+v %+v", r, r.Request.Header)
_ = r.Body.Close()
up(50 + float64(i)*50/float64(count))
up(float64(i) * 100 / float64(count))
}
fileMd5 := hex.EncodeToString(md5Sum.Sum(nil))
sliceMd5 := fileMd5
if file.GetSize() > DEFAULT {
sliceMd5 = utils.GetMD5EncodeStr(strings.Join(md5s, "\n"))
}
res, err = d.uploadRequest("/person/commitMultiUploadFile", map[string]string{
"uploadFileId": uploadFileId,
"fileMd5": fileMd5Hex,
"sliceMd5": sliceMd5Hex,
"fileMd5": fileMd5,
"sliceMd5": sliceMd5,
"lazyCheck": "1",
"opertype": "3",
}, nil)
if err != nil {
return err
}
// 生成 torrent 文件(异步,不影响上传结果)
capturedDstDir := dstDir
capturedFileName := file.GetName()
capturedFileSize := fileSize
capturedFileMd5Hex := fileMd5Hex
capturedMd5s := md5s
go func() {
fileMD5Upper := strings.ToUpper(capturedFileMd5Hex)
torrentData, err := GenerateTorrent(capturedFileName, capturedFileSize, fileMD5Upper, capturedMd5s, DEFAULT, pieceSHA1Hashes)
if err != nil {
log.Warnf("生成 torrent 失败: %v", err)
return
}
infoHash, _ := GetInfoHashHex(torrentData)
torrentName := capturedFileName + ".cas.torrent"
log.Infof("已生成 torrent: %s (info_hash: %s, size: %d bytes)",
torrentName, infoHash, len(torrentData))
// 将 torrent 文件上传到同一目录
torrentFileStream := &stream.FileStream{
Ctx: context.Background(),
Obj: &model.Object{
Name: torrentName,
Size: int64(len(torrentData)),
IsFolder: false,
},
Reader: bytes.NewReader(torrentData),
Mimetype: "application/x-bittorrent",
}
uploadErr := d.oldUpload(capturedDstDir, torrentFileStream)
if uploadErr != nil {
log.Warnf("上传 torrent 文件失败: %v", uploadErr)
} else {
log.Infof("torrent 文件已上传: %s", torrentName)
op.Cache.DeleteDirectory(d, capturedDstDir.GetPath())
}
}()
return nil
return err
}
func (d *Cloud189) getCapacityInfo(ctx context.Context) (*CapacityResp, error) {
@@ -505,52 +406,3 @@ func (d *Cloud189) getCapacityInfo(ctx context.Context) (*CapacityResp, error) {
}
return &resp, nil
}
// sliceHashWriter 在写入过程中按分片大小自动切分并计算每个分片的MD5,
// 同时支持进度回调和取消检查。
type sliceHashWriter struct {
hash io.Writer // 当前分片的MD5 hash
md5s *[]string // 收集每个分片的MD5十六进制字符串
sliceSize int64 // 分片大小
finish *int64 // 已写入的总字节数
fileSize int64 // 文件总大小
up driver.UpdateProgress
ctx context.Context
}
func (w *sliceHashWriter) Write(p []byte) (int, error) {
if utils.IsCanceled(w.ctx) {
return 0, w.ctx.Err()
}
total := len(p)
written := 0
for written < total {
// 当前分片还能写入的字节数
sliceRemain := w.sliceSize - (*w.finish % w.sliceSize)
toWrite := int64(total - written)
if toWrite > sliceRemain {
toWrite = sliceRemain
}
n, err := w.hash.Write(p[written : written+int(toWrite)])
if err != nil {
return written, err
}
written += n
*w.finish += int64(n)
// 当前分片写满,记录MD5并重置
if *w.finish%w.sliceSize == 0 {
if h, ok := w.hash.(interface{ Sum([]byte) []byte }); ok {
*w.md5s = append(*w.md5s, strings.ToUpper(hex.EncodeToString(h.Sum(nil))))
}
if resetter, ok := w.hash.(interface{ Reset() }); ok {
resetter.Reset()
}
}
}
// 报告进度(缓存阶段占50%)
if w.fileSize > 0 && w.up != nil {
w.up(float64(*w.finish) / float64(w.fileSize) * 50)
}
return total, nil
}
+4 -4
View File
@@ -284,18 +284,18 @@ func (y *Cloud189TV) GetDetails(ctx context.Context) (*model.StorageDetails, err
if err != nil {
return nil, err
}
var total, used int64
var total, free uint64
if y.isFamily() {
total = capacityInfo.FamilyCapacityInfo.TotalSize
used = capacityInfo.FamilyCapacityInfo.UsedSize
free = capacityInfo.FamilyCapacityInfo.FreeSize
} else {
total = capacityInfo.CloudCapacityInfo.TotalSize
used = capacityInfo.CloudCapacityInfo.UsedSize
free = capacityInfo.CloudCapacityInfo.FreeSize
}
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
FreeSpace: free,
},
}, nil
}
+7 -7
View File
@@ -322,15 +322,15 @@ type CapacityResp struct {
ResMessage string `json:"res_message"`
Account string `json:"account"`
CloudCapacityInfo struct {
FreeSize int64 `json:"freeSize"`
MailUsedSize int64 `json:"mail189UsedSize"`
TotalSize int64 `json:"totalSize"`
UsedSize int64 `json:"usedSize"`
FreeSize uint64 `json:"freeSize"`
MailUsedSize uint64 `json:"mail189UsedSize"`
TotalSize uint64 `json:"totalSize"`
UsedSize uint64 `json:"usedSize"`
} `json:"cloudCapacityInfo"`
FamilyCapacityInfo struct {
FreeSize int64 `json:"freeSize"`
TotalSize int64 `json:"totalSize"`
UsedSize int64 `json:"usedSize"`
FreeSize uint64 `json:"freeSize"`
TotalSize uint64 `json:"totalSize"`
UsedSize uint64 `json:"usedSize"`
} `json:"familyCapacityInfo"`
TotalSize uint64 `json:"totalSize"`
}
+16 -29
View File
@@ -87,12 +87,7 @@ func (y *Cloud189PC) Init(ctx context.Context) (err error) {
}
// 先尝试用Token刷新,之后尝试登陆
if y.Addition.AccessToken != "" {
y.tokenInfo = &AppSessionResp{AccessToken: y.Addition.AccessToken, RefreshToken: y.Addition.RefreshToken}
if err = y.refreshSession(); err != nil {
return err
}
} else if y.Addition.RefreshToken != "" {
if y.Addition.RefreshToken != "" {
y.tokenInfo = &AppSessionResp{RefreshToken: y.Addition.RefreshToken}
if err = y.refreshToken(); err != nil {
return err
@@ -276,35 +271,29 @@ func (y *Cloud189PC) Rename(ctx context.Context, srcObj model.Obj, newName strin
queryParam["familyId"] = y.FamilyID
}
switch srcObj.(type) {
var newObj model.Obj
switch f := srcObj.(type) {
case *Cloud189File:
fullUrl += "/renameFile.action"
queryParam["fileId"] = srcObj.GetID()
queryParam["destFileName"] = newName
newObj = &Cloud189File{Icon: f.Icon} // 复用预览
case *Cloud189Folder:
fullUrl += "/renameFolder.action"
queryParam["folderId"] = srcObj.GetID()
queryParam["destFolderName"] = newName
newObj = &Cloud189Folder{}
default:
return nil, errs.NotSupport
}
var resp RenameResp
_, err := y.request(fullUrl, method, func(req *resty.Request) {
req.SetContext(ctx).SetQueryParams(queryParam)
}, nil, &resp, isFamily)
}, nil, newObj, isFamily)
if err != nil {
if code, ok := resp.ResCode.(string); ok && code == "FileAlreadyExists" {
return nil, errs.ObjectAlreadyExists
}
return nil, err
}
switch f := srcObj.(type) {
case *Cloud189File:
return resp.toFile(f), nil
case *Cloud189Folder:
return resp.toFolder(), nil
}
return nil, errs.NotSupport
return newObj, nil
}
func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
@@ -343,7 +332,6 @@ func (y *Cloud189PC) Put(ctx context.Context, dstDir model.Obj, stream model.Fil
// 响应时间长,按需启用
if y.Addition.RapidUpload && !stream.IsForceStreamUpload() {
// 尝试妙传
if newObj, err := y.RapidUpload(ctx, dstDir, stream, isFamily, overwrite); err == nil {
return newObj, nil
}
@@ -352,11 +340,10 @@ func (y *Cloud189PC) Put(ctx context.Context, dstDir model.Obj, stream model.Fil
uploadMethod := y.UploadMethod
if stream.IsForceStreamUpload() {
uploadMethod = "stream"
} else if y.Addition.RapidUpload && stream.GetFile() != nil {
// 文件流支持随机读取,走FastUpload计算MD5并尝试秒传
uploadMethod = "rapid"
} else if uploadMethod == "old" {
// 旧版上传家庭云也有限制
}
// 旧版上传家庭云也有限制
if uploadMethod == "old" {
return y.OldUpload(ctx, dstDir, stream, up, isFamily, overwrite)
}
@@ -429,18 +416,18 @@ func (y *Cloud189PC) GetDetails(ctx context.Context) (*model.StorageDetails, err
if err != nil {
return nil, err
}
var total, used int64
var total, free uint64
if y.isFamily() {
total = capacityInfo.FamilyCapacityInfo.TotalSize
used = capacityInfo.FamilyCapacityInfo.UsedSize
free = capacityInfo.FamilyCapacityInfo.FreeSize
} else {
total = capacityInfo.CloudCapacityInfo.TotalSize
used = capacityInfo.CloudCapacityInfo.UsedSize
free = capacityInfo.CloudCapacityInfo.FreeSize
}
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
FreeSpace: free,
},
}, nil
}
+9 -11
View File
@@ -10,19 +10,17 @@ type Addition struct {
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true"`
VCode string `json:"validate_code"`
AccessToken string `json:"access_token" required:"false"`
RefreshToken string `json:"refresh_token" help:"To switch accounts, please clear this field"`
driver.RootID
OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"`
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
Type string `json:"type" type:"select" options:"personal,family" default:"personal"`
FamilyID string `json:"family_id"`
UploadMethod string `json:"upload_method" type:"select" options:"stream,rapid,old" default:"stream"`
UploadThread string `json:"upload_thread" default:"3" help:"1<=thread<=32"`
FamilyTransfer bool `json:"family_transfer"`
RapidUpload bool `json:"rapid_upload"`
NoUseOcr bool `json:"no_use_ocr"`
GenerateTorrent bool `json:"generate_torrent" help:"Generate torrent file with CAS extension after upload"`
OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"`
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
Type string `json:"type" type:"select" options:"personal,family" default:"personal"`
FamilyID string `json:"family_id"`
UploadMethod string `json:"upload_method" type:"select" options:"stream,rapid,old" default:"stream"`
UploadThread string `json:"upload_thread" default:"3" help:"1<=thread<=32"`
FamilyTransfer bool `json:"family_transfer"`
RapidUpload bool `json:"rapid_upload"`
NoUseOcr bool `json:"no_use_ocr"`
}
var config = driver.Config{
-291
View File
@@ -1,291 +0,0 @@
package _189pc
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"net/url"
"strings"
"github.com/go-resty/resty/v2"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// GenerateTorrent 根据上传过程中收集的哈希信息生成包含 CAS 扩展的 torrent 文件
// fileMD5: 整文件 MD5(大写十六进制)
// sliceMD5s: 每个分片的 MD5 列表(大写十六进制)
// sliceSize: 分片大小
// pieceHashes: SHA-1 piece hashes 拼接(每 20 字节一个)
// fileName: 文件名
// fileSize: 文件大小
func GenerateTorrent(fileName string, fileSize int64, fileMD5 string, sliceMD5s []string, sliceSize int64, pieceHashes []byte) ([]byte, error) {
// 计算 sliceMD5
sliceMD5 := fileMD5
if len(sliceMD5s) > 1 {
joined := strings.Join(sliceMD5s, "\n")
sliceMD5 = strings.ToUpper(torrent.GetMD5Str(joined))
}
t := torrent.NewTorrent(fileName, fileSize, fileMD5)
t.Info.PieceLength = sliceSize
t.SetPieces(pieceHashes)
t.SetCASInfo(&torrent.CASInfo{
FileMD5: fileMD5,
SliceMD5: sliceMD5,
SliceMD5s: sliceMD5s,
SliceSize: sliceSize,
Cloud: "189",
})
return t.Encode()
}
// RapidUploadFromTorrent 从 torrent 文件中提取 CAS 信息进行秒传
// 返回值:上传成功的文件对象、错误
func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Obj, torrentData []byte, overwrite bool) (model.Obj, error) {
isFamily := y.isFamily()
// 解析 torrent
t, err := torrent.Decode(torrentData)
if err != nil {
return nil, fmt.Errorf("解析 torrent 失败: %w", err)
}
// 检查是否包含 CAS 扩展信息
if !t.HasCASInfo() {
return nil, fmt.Errorf("torrent 不包含 CAS 扩展信息,无法秒传")
}
cas := t.CAS
fileName := t.Info.Name
fileSize := t.GetTotalSize()
// 统一 MD5 为大写(与正常上传保持一致,天翼云盘要求大写)
fileMD5Upper := strings.ToUpper(cas.FileMD5)
// 优先使用 torrent 中嵌入的分片大小,与生成时保持一致
sliceSize := cas.SliceSize
if sliceSize <= 0 {
sliceSize = partSize(fileSize)
}
// 计算 sliceMd5(与上传时一致的算法)
// 优先使用 torrent 中已有的 SliceMD5;仅当有多分片列表时才重新计算
sliceMd5Hex := strings.ToUpper(cas.SliceMD5)
if sliceMd5Hex == "" {
sliceMd5Hex = fileMD5Upper
}
if len(cas.SliceMD5s) > 1 {
// 分片 MD5 也需要统一大写后再拼接计算
upperSliceMD5s := make([]string, len(cas.SliceMD5s))
for i, s := range cas.SliceMD5s {
upperSliceMD5s[i] = strings.ToUpper(s)
}
sliceMd5Hex = strings.ToUpper(utils.GetMD5EncodeStr(strings.Join(upperSliceMD5s, "\n")))
}
// 使用与 Web 端一致的三步秒传流程
fullUrl := "https://upload.cloud.189.cn"
if isFamily {
fullUrl += "/family"
} else {
fullUrl += "/person"
}
// Step 1: initMultiUpload(不传 fileMd5/sliceMd5,只传 lazyCheck)
initParams := Params{
"parentFolderId": dstDir.GetID(),
"fileName": url.QueryEscape(fileName),
"fileSize": fmt.Sprint(fileSize),
"sliceSize": fmt.Sprint(sliceSize),
"lazyCheck": "1",
}
if isFamily {
initParams.Set("familyId", y.FamilyID)
}
var uploadInfo InitMultiUploadResp
_, err = y.request(fullUrl+"/initMultiUpload", "GET", func(req *resty.Request) {
req.SetContext(ctx)
}, initParams, &uploadInfo, isFamily)
if err != nil {
return nil, fmt.Errorf("initMultiUpload 失败: %w", err)
}
uploadFileId := uploadInfo.Data.UploadFileID
// Step 2: checkTransSecond(用 fileMd5 + sliceMd5 + uploadFileId 检查秒传)
checkParams := Params{
"fileMd5": fileMD5Upper,
"sliceMd5": sliceMd5Hex,
"uploadFileId": uploadFileId,
}
var checkResp struct {
Data struct {
FileDataExists int `json:"fileDataExists"`
} `json:"data"`
}
_, err = y.request(fullUrl+"/checkTransSecond", "GET", func(req *resty.Request) {
req.SetContext(ctx)
}, checkParams, &checkResp, isFamily)
if err != nil {
utils.Log.Errorf("[RapidUpload] checkTransSecond 失败: uploadFileId=%s, err=%v", uploadFileId, err)
return nil, fmt.Errorf("秒传检查失败: %w", err)
}
if checkResp.Data.FileDataExists != 1 {
return nil, fmt.Errorf("秒传失败:云端不存在该文件(fileMD5=%s, sliceMD5=%s, size=%d)", fileMD5Upper, sliceMd5Hex, fileSize)
}
// Step 3: commitMultiUploadFile(传 fileMd5 + sliceMd5)
var resp CommitMultiUploadFileResp
commitParams := Params{
"uploadFileId": uploadFileId,
"fileMd5": fileMD5Upper,
"sliceMd5": sliceMd5Hex,
"lazyCheck": "1",
"opertype": IF(overwrite, "3", "1"),
}
_, err = y.request(fullUrl+"/commitMultiUploadFile", "GET", func(req *resty.Request) {
req.SetContext(ctx)
}, commitParams, &resp, isFamily)
if err != nil {
utils.Log.Errorf("[RapidUpload] commitMultiUploadFile 失败: uploadFileId=%s, err=%v", uploadFileId, err)
return nil, fmt.Errorf("提交上传失败: %w", err)
}
return resp.toFile(), nil
}
// ComputeTorrentFromReader 从 io.Reader 计算并生成 torrent 文件
// 适用于:已有文件需要生成 torrent 的场景(如下载完成后生成)
func ComputeTorrentFromReader(reader io.Reader, fileName string, fileSize int64, sliceSize int64) ([]byte, error) {
if sliceSize <= 0 {
sliceSize = torrent.DefaultPieceSize
}
hw := torrent.NewHashWriter(sliceSize, sliceSize)
buf := make([]byte, 32*1024)
for {
n, err := reader.Read(buf)
if n > 0 {
hw.Write(buf[:n])
}
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
}
hw.Finish()
fileMD5 := hw.GetFileMD5()
sliceMD5s := hw.GetSliceMD5s()
pieceHashes := hw.GetPieceHashes()
return GenerateTorrent(fileName, fileSize, fileMD5, sliceMD5s, sliceSize, pieceHashes)
}
// ComputePieceSHA1 计算单个分片的 SHA-1 哈希
func ComputePieceSHA1(data []byte) []byte {
h := sha1.Sum(data)
return h[:]
}
// ExtractCASFromTorrent 从 torrent 数据中提取 CAS 信息
// 返回:CAS 信息、文件名、文件大小、错误
func ExtractCASFromTorrent(torrentData []byte) (*torrent.CASInfo, string, int64, error) {
t, err := torrent.Decode(torrentData)
if err != nil {
return nil, "", 0, fmt.Errorf("解析 torrent 失败: %w", err)
}
if !t.HasCASInfo() {
return nil, "", 0, fmt.Errorf("torrent 不包含 CAS 扩展信息")
}
return t.CAS, t.Info.Name, t.GetTotalSize(), nil
}
// InjectCASIntoTorrent 向已有的 torrent 文件注入 CAS 扩展信息
// 用于:下载完成后,计算了 MD5 信息,写回到 torrent 中
func InjectCASIntoTorrent(torrentData []byte, fileMD5 string, sliceMD5s []string, sliceSize int64) ([]byte, error) {
t, err := torrent.Decode(torrentData)
if err != nil {
return nil, fmt.Errorf("解析 torrent 失败: %w", err)
}
// 计算 sliceMD5
sliceMD5 := fileMD5
if len(sliceMD5s) > 1 {
joined := strings.Join(sliceMD5s, "\n")
sliceMD5 = strings.ToUpper(torrent.GetMD5Str(joined))
}
// 注入 CAS 信息
t.SetCASInfo(&torrent.CASInfo{
FileMD5: fileMD5,
SliceMD5: sliceMD5,
SliceMD5s: sliceMD5s,
SliceSize: sliceSize,
Cloud: "189",
})
// 同时更新 info 中的 md5sum 字段
if t.Info.MD5Sum == "" {
t.Info.MD5Sum = fileMD5
}
return t.Encode()
}
// GetInfoHashHex 获取 torrent 的 info_hash(十六进制字符串)
func GetInfoHashHex(torrentData []byte) (string, error) {
t, err := torrent.Decode(torrentData)
if err != nil {
return "", err
}
return hex.EncodeToString(t.InfoHash), nil
}
// ComputeSliceMD5sFromReader 从 reader 中计算每个 10MB 分片的 MD5
// 返回:整文件 MD5、分片 MD5 列表
func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []string, error) {
if sliceSize <= 0 {
sliceSize = torrent.DefaultPieceSize
}
fileMD5Hash := utils.MD5.NewFunc()
sliceMD5s := make([]string, 0)
buf := make([]byte, sliceSize)
for {
n, err := io.ReadFull(reader, buf)
if n > 0 {
chunk := buf[:n]
fileMD5Hash.Write(chunk)
// 计算该分片的 MD5
sliceMD5 := strings.ToUpper(utils.HashData(utils.MD5, chunk))
sliceMD5s = append(sliceMD5s, sliceMD5)
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
break
}
if err != nil {
return "", nil, err
}
}
fileMD5Hex := strings.ToUpper(hex.EncodeToString(fileMD5Hash.Sum(nil)))
return fileMD5Hex, sliceMD5s, nil
}
+7 -45
View File
@@ -415,53 +415,15 @@ type CapacityResp struct {
ResMessage string `json:"res_message"`
Account string `json:"account"`
CloudCapacityInfo struct {
FreeSize int64 `json:"freeSize"`
MailUsedSize int64 `json:"mail189UsedSize"`
TotalSize int64 `json:"totalSize"`
UsedSize int64 `json:"usedSize"`
FreeSize uint64 `json:"freeSize"`
MailUsedSize uint64 `json:"mail189UsedSize"`
TotalSize uint64 `json:"totalSize"`
UsedSize uint64 `json:"usedSize"`
} `json:"cloudCapacityInfo"`
FamilyCapacityInfo struct {
FreeSize int64 `json:"freeSize"`
TotalSize int64 `json:"totalSize"`
UsedSize int64 `json:"usedSize"`
FreeSize uint64 `json:"freeSize"`
TotalSize uint64 `json:"totalSize"`
UsedSize uint64 `json:"usedSize"`
} `json:"familyCapacityInfo"`
TotalSize uint64 `json:"totalSize"`
}
type RenameResp struct {
ResMsg string `json:"res_message"`
CreateDate Time `json:"createDate"`
FileCate int `json:"fileCata"`
ID String `json:"id"`
LastOpTime Time `json:"lastOpTime"`
MD5 string `json:"md5"`
MediaType int `json:"mediaType"`
Name string `json:"name"`
Oeientation int `json:"orientation"`
ParentID int64 `json:"parentId"`
Rev string `json:"rev"`
Size int64 `json:"size"`
ResCode any `json:"res_code"` // int or string
}
func (r *RenameResp) toFile(f *Cloud189File) *Cloud189File {
return &Cloud189File{
ID: r.ID,
Name: r.Name,
Size: r.Size,
Md5: r.MD5,
LastOpTime: r.LastOpTime,
CreateDate: r.CreateDate,
Icon: f.Icon,
}
}
func (r *RenameResp) toFolder() *Cloud189Folder {
return &Cloud189Folder{
ID: r.ID,
Name: r.Name,
ParentID: r.ParentID,
LastOpTime: r.LastOpTime,
CreateDate: r.CreateDate,
}
}
+22 -81
View File
@@ -3,7 +3,6 @@ package _189pc
import (
"bytes"
"context"
sha1Pkg "crypto/sha1"
"encoding/base64"
"encoding/hex"
"encoding/xml"
@@ -357,7 +356,6 @@ func (y *Cloud189PC) loginByPassword() (err error) {
err = fmt.Errorf(tokenInfo.ResMessage)
return err
}
y.Addition.AccessToken = tokenInfo.AccessToken
y.Addition.RefreshToken = tokenInfo.RefreshToken
y.tokenInfo = &tokenInfo
op.MustSaveDriverStorage(y)
@@ -416,7 +414,6 @@ func (y *Cloud189PC) loginByQRCode() error {
if tokenInfo.ResCode != 0 {
return fmt.Errorf(tokenInfo.ResMessage)
}
y.Addition.AccessToken = tokenInfo.AccessToken
y.Addition.RefreshToken = tokenInfo.RefreshToken
y.tokenInfo = &tokenInfo
op.MustSaveDriverStorage(y)
@@ -664,7 +661,6 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
return y.login()
}
y.Addition.AccessToken = tokenInfo.AccessToken
y.Addition.RefreshToken = tokenInfo.RefreshToken
y.tokenInfo = &tokenInfo
op.MustSaveDriverStorage(y)
@@ -743,10 +739,6 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo
silceMd5 := utils.MD5.NewFunc()
var writers io.Writer = silceMd5
// 如果启用了 torrent 生成,额外计算 SHA-1 piece hash
generateTorrent := y.Addition.GenerateTorrent
pieceSHA1Hashes := make([]byte, 0, count*20)
fileMd5Hex := file.GetHash().GetHash(utils.MD5)
var fileMd5 hash.Hash
if len(fileMd5Hex) != utils.MD5.Width {
@@ -764,40 +756,30 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo
}
partInfo := ""
var reader io.ReadSeeker
var rateLimitedRd io.Reader
threadG.GoWithLifecycle(errgroup.Lifecycle{
Before: func(ctx context.Context) (err error) {
reader, err = ss.GetSectionReader(offset, partSize)
if err != nil {
return err
}
silceMd5.Reset()
Before: func(ctx context.Context) error {
if reader == nil {
var err error
reader, err = ss.GetSectionReader(offset, partSize)
if err != nil {
return err
}
silceMd5.Reset()
w, err := utils.CopyWithBuffer(writers, reader)
if w != partSize {
return fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", partSize, w, err)
}
// 计算块md5并进行hex和base64编码
md5Bytes := silceMd5.Sum(nil)
silceMd5Hexs = append(silceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Bytes)))
partInfo = fmt.Sprintf("%d-%s", i, base64.StdEncoding.EncodeToString(md5Bytes))
// 如果需要生成 torrent,同时计算 SHA-1
var sha1Writer hash.Hash
var multiWriter io.Writer
if generateTorrent {
sha1Writer = sha1Pkg.New()
multiWriter = io.MultiWriter(writers, sha1Writer)
} else {
multiWriter = writers
}
w, err := utils.CopyWithBuffer(multiWriter, reader)
if w != partSize {
return fmt.Errorf("failed to read all data: (expect =%d, actual =%d) %w", partSize, w, err)
}
// 计算块md5并进行hex和base64编码
md5Bytes := silceMd5.Sum(nil)
silceMd5Hexs = append(silceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Bytes)))
partInfo = fmt.Sprintf("%d-%s", i, base64.StdEncoding.EncodeToString(md5Bytes))
// 收集 SHA-1 piece hash
if generateTorrent && sha1Writer != nil {
pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Writer.Sum(nil)...)
rateLimitedRd = driver.NewLimitedUploadStream(ctx, reader)
}
return nil
},
Do: func(ctx context.Context) (err error) {
Do: func(ctx context.Context) error {
reader.Seek(0, io.SeekStart)
uploadUrls, err := y.GetMultiUploadUrls(ctx, isFamily, initMultiUpload.Data.UploadFileID, partInfo)
if err != nil {
@@ -806,11 +788,11 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo
// step.4 上传切片
uploadUrl := uploadUrls[0]
_, err = y.put(ctx, uploadUrl.RequestURL, uploadUrl.Headers, false, driver.NewLimitedUploadStream(ctx, reader), isFamily)
_, err = y.put(ctx, uploadUrl.RequestURL, uploadUrl.Headers, false, rateLimitedRd, isFamily)
if err != nil {
return err
}
up(float64(threadG.Success()+1) * 100 / float64(count+1))
up(float64(threadG.Success()) * 100 / float64(count))
return nil
},
After: func(err error) {
@@ -822,7 +804,6 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo
if err = threadG.Wait(); err != nil {
return nil, err
}
defer up(100)
if fileMd5 != nil {
fileMd5Hex = strings.ToUpper(hex.EncodeToString(fileMd5.Sum(nil)))
@@ -848,45 +829,6 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo
if err != nil {
return nil, err
}
// 生成 torrent 文件(异步,不影响上传结果)
if generateTorrent && len(pieceSHA1Hashes) > 0 {
// 捕获必要的变量
capturedDstDir := dstDir
capturedIsFamily := isFamily
capturedFileName := file.GetName()
go func() {
torrentData, err := GenerateTorrent(capturedFileName, fileSize, fileMd5Hex, silceMd5Hexs, sliceSize, pieceSHA1Hashes)
if err != nil {
utils.Log.Warnf("生成 torrent 失败: %v", err)
return
}
infoHash, _ := GetInfoHashHex(torrentData)
torrentName := capturedFileName + ".cas.torrent"
utils.Log.Infof("已生成 torrent: %s (info_hash: %s, size: %d bytes)",
torrentName, infoHash, len(torrentData))
// 将 torrent 文件上传到同一目录(使用 FastUpload,因为 torrent 文件很小)
torrentFileStream := &stream.FileStream{
Ctx: context.Background(),
Obj: &model.Object{
Name: torrentName,
Size: int64(len(torrentData)),
IsFolder: false,
},
Reader: bytes.NewReader(torrentData),
Mimetype: "application/x-bittorrent",
}
_, uploadErr := y.FastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false)
if uploadErr != nil {
utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr)
} else {
utils.Log.Infof("torrent 文件已上传: %s", torrentName)
op.Cache.DeleteDirectory(y, capturedDstDir.GetPath())
}
}()
}
return resp.toFile(), nil
}
@@ -1053,7 +995,7 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
return err
}
up(float64(threadG.Success()+1) * 100 / float64(len(uploadUrls)+1))
up(float64(threadG.Success()) * 100 / float64(len(uploadUrls)))
uploadProgress.UploadParts[i] = ""
return nil
})
@@ -1065,7 +1007,6 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
}
return nil, err
}
defer up(100)
}
// step.5 提交
+346 -377
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"net/url"
stdpath "path"
"strings"
@@ -17,7 +16,6 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/sign"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
)
@@ -25,9 +23,10 @@ import (
type Alias struct {
model.Storage
Addition
rootOrder []string
pathMap map[string][]string
root model.Obj
rootOrder []string
pathMap map[string][]string
autoFlatten bool
oneKey string
}
func (d *Alias) Config() driver.Config {
@@ -39,6 +38,9 @@ func (d *Alias) GetAddition() driver.Additional {
}
func (d *Alias) Init(ctx context.Context) error {
if d.Paths == "" {
return errors.New("paths is required")
}
paths := strings.Split(d.Paths, "\n")
d.rootOrder = make([]string, 0, len(paths))
d.pathMap = make(map[string][]string)
@@ -48,50 +50,19 @@ func (d *Alias) Init(ctx context.Context) error {
continue
}
k, v := getPair(path)
temp, ok := d.pathMap[k]
if !ok {
if _, ok := d.pathMap[k]; !ok {
d.rootOrder = append(d.rootOrder, k)
}
d.pathMap[k] = append(temp, v)
d.pathMap[k] = append(d.pathMap[k], v)
}
switch len(d.rootOrder) {
case 0:
return errors.New("paths is required")
case 1:
paths := d.pathMap[d.rootOrder[0]]
roots := make(BalancedObjs, 0, len(paths))
roots = append(roots, &model.Object{
Name: "root",
Path: paths[0],
IsFolder: true,
Modified: d.Modified,
Mask: model.Locked,
})
for _, path := range paths[1:] {
roots = append(roots, &model.Object{
Path: path,
})
if len(d.pathMap) == 1 {
for k := range d.pathMap {
d.oneKey = k
}
d.root = roots
default:
d.root = &model.Object{
Name: "root",
Path: "/",
IsFolder: true,
Modified: d.Modified,
Mask: model.ReadOnly,
}
}
if !utils.SliceContains(ValidReadConflictPolicy, d.ReadConflictPolicy) {
d.ReadConflictPolicy = FirstRWP
}
if !utils.SliceContains(ValidWriteConflictPolicy, d.WriteConflictPolicy) {
d.WriteConflictPolicy = DisabledWP
}
if !utils.SliceContains(ValidPutConflictPolicy, d.PutConflictPolicy) {
d.PutConflictPolicy = DisabledWP
d.autoFlatten = true
} else {
d.oneKey = ""
d.autoFlatten = false
}
return nil
}
@@ -99,329 +70,310 @@ func (d *Alias) Init(ctx context.Context) error {
func (d *Alias) Drop(ctx context.Context) error {
d.rootOrder = nil
d.pathMap = nil
d.root = nil
return nil
}
func (d *Alias) GetRoot(ctx context.Context) (model.Obj, error) {
if d.root == nil {
return nil, errs.StorageNotInit
}
return d.root, nil
}
// 通过op.Get调用的话,path一定是子路径(/开头)
func (d *Alias) Get(ctx context.Context, path string) (model.Obj, error) {
roots, sub := d.getRootsAndPath(path)
if len(roots) == 0 {
if utils.PathEqual(path, "/") {
return &model.Object{
Name: "Root",
IsFolder: true,
Path: "/",
}, nil
}
root, sub := d.getRootAndPath(path)
dsts, ok := d.pathMap[root]
if !ok {
return nil, errs.ObjectNotFound
}
for idx, root := range roots {
rawPath := stdpath.Join(root, sub)
var ret *model.Object
provider := ""
for _, dst := range dsts {
rawPath := stdpath.Join(dst, sub)
obj, err := fs.Get(ctx, rawPath, &fs.GetArgs{NoLog: true})
if err != nil {
continue
}
mask := model.GetObjMask(obj) &^ model.Temp
if sub == "" {
// 根目录
mask |= model.Locked | model.Virtual
}
ret := model.Object{
Path: rawPath,
Name: obj.GetName(),
Size: obj.GetSize(),
Modified: obj.ModTime(),
IsFolder: obj.IsDir(),
HashInfo: obj.GetHash(),
Mask: mask,
}
obj = &ret
if d.ProviderPassThrough && !obj.IsDir() {
if storage, err := fs.GetStorage(rawPath, &fs.GetStoragesArgs{}); err == nil {
obj = &model.ObjectProvider{
Object: ret,
Provider: model.Provider{
Provider: storage.Config().Name,
},
}
storage, err := fs.GetStorage(rawPath, &fs.GetStoragesArgs{})
if ret == nil {
ret = &model.Object{
Path: path,
Name: obj.GetName(),
Size: obj.GetSize(),
Modified: obj.ModTime(),
IsFolder: obj.IsDir(),
HashInfo: obj.GetHash(),
}
if !d.ProviderPassThrough || err != nil {
break
}
provider = storage.Config().Name
} else if err != nil || provider != storage.GetStorage().Driver {
provider = ""
break
}
roots = roots[idx+1:]
var objs BalancedObjs
if idx > 0 {
objs = make(BalancedObjs, 0, len(roots)+2)
} else {
objs = make(BalancedObjs, 0, len(roots)+1)
}
objs = append(objs, obj)
if idx > 0 {
objs = append(objs, nil)
}
for _, d := range roots {
objs = append(objs, &tempObj{model.Object{
Path: stdpath.Join(d, sub),
}})
}
return objs, nil
}
return nil, errs.ObjectNotFound
if ret == nil {
return nil, errs.ObjectNotFound
}
if provider != "" {
return &model.ObjectProvider{
Object: *ret,
Provider: model.Provider{
Provider: provider,
},
}, nil
}
return ret, nil
}
func (d *Alias) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
dirs, ok := dir.(BalancedObjs)
if !ok {
path := dir.GetPath()
if utils.PathEqual(path, "/") && !d.autoFlatten {
return d.listRoot(ctx, args.WithStorageDetails && d.DetailsPassThrough, args.Refresh), nil
}
// 因为alias是NoCache且Get方法不会返回NotSupport或NotImplement错误
// 所以这里对象不会传回到alias,也就不需要返回BalancedObjs了
objMap := make(map[string]model.Obj)
for _, dir := range dirs {
if dir == nil {
continue
}
dirPath := dir.GetPath()
tmp, err := fs.List(ctx, dirPath, &fs.ListArgs{
root, sub := d.getRootAndPath(path)
dsts, ok := d.pathMap[root]
if !ok {
return nil, errs.ObjectNotFound
}
var objs []model.Obj
for _, dst := range dsts {
tmp, err := fs.List(ctx, stdpath.Join(dst, sub), &fs.ListArgs{
NoLog: true,
Refresh: args.Refresh,
WithStorageDetails: args.WithStorageDetails && d.DetailsPassThrough,
})
if err != nil {
continue
}
for _, obj := range tmp {
name := obj.GetName()
if _, exists := objMap[name]; exists {
continue
}
mask := model.GetObjMask(obj) &^ model.Temp
objRes := model.Object{
Name: name,
Path: stdpath.Join(dirPath, name),
Size: obj.GetSize(),
Modified: obj.ModTime(),
IsFolder: obj.IsDir(),
Mask: mask,
}
var objRet model.Obj
if thumb, ok := model.GetThumb(obj); ok {
objRet = &model.ObjThumb{
Object: objRes,
Thumbnail: model.Thumbnail{
Thumbnail: thumb,
},
if err == nil {
tmp, err = utils.SliceConvert(tmp, func(obj model.Obj) (model.Obj, error) {
objRes := model.Object{
Name: obj.GetName(),
Size: obj.GetSize(),
Modified: obj.ModTime(),
IsFolder: obj.IsDir(),
}
} else {
objRet = &objRes
}
if details, ok := model.GetStorageDetails(obj); ok {
objRet = &model.ObjStorageDetails{
Obj: objRet,
StorageDetails: details,
if thumb, ok := model.GetThumb(obj); ok {
return &model.ObjThumb{
Object: objRes,
Thumbnail: model.Thumbnail{
Thumbnail: thumb,
},
}, nil
}
}
objMap[name] = objRet
if details, ok := model.GetStorageDetails(obj); ok {
return &model.ObjStorageDetails{
Obj: &objRes,
StorageDetailsWithName: *details,
}, nil
}
return &objRes, nil
})
}
}
objs := make([]model.Obj, 0, len(objMap))
for _, obj := range objMap {
objs = append(objs, obj)
}
if d.OrderBy == "" {
sort := getAllSort(dirs)
if sort.OrderBy != "" {
model.SortFiles(objs, sort.OrderBy, sort.OrderDirection)
}
if d.ExtractFolder == "" && sort.ExtractFolder != "" {
model.ExtractFolder(objs, sort.ExtractFolder)
if err == nil {
objs = append(objs, tmp...)
}
}
return objs, nil
}
func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if d.ReadConflictPolicy == AllRWP && !args.Redirect {
files, err := d.getAllObjs(ctx, file, getWriteAndPutFilterFunc(AllRWP))
root, sub := d.getRootAndPath(file.GetPath())
dsts, ok := d.pathMap[root]
if !ok {
return nil, errs.ObjectNotFound
}
// proxy || ftp,s3
if common.GetApiUrl(ctx) == "" {
args.Redirect = false
}
for _, dst := range dsts {
reqPath := stdpath.Join(dst, sub)
link, fi, err := d.link(ctx, reqPath, args)
if err != nil {
return nil, err
}
linkClosers := make([]io.Closer, 0, len(files))
rrf := make([]model.RangeReaderIF, 0, len(files))
requireReference := false
for _, f := range files {
link, fi, err := d.link(ctx, f.GetPath(), args)
if err != nil {
continue
}
if fi.GetSize() != files.GetSize() {
_ = link.Close()
continue
}
l := &model.Link{
URL: link.URL,
Header: link.Header,
RangeReader: link.RangeReader,
Concurrency: link.Concurrency,
PartSize: link.PartSize,
ContentLength: link.ContentLength,
}
if d.DownloadConcurrency > 0 {
l.Concurrency = d.DownloadConcurrency
}
if d.DownloadPartSize > 0 {
l.PartSize = d.DownloadPartSize * utils.KB
}
if l.ContentLength == 0 {
l.ContentLength = fi.GetSize()
}
rr, err := stream.GetRangeReaderFromLink(l.ContentLength, l)
if err != nil {
_ = link.Close()
continue
}
linkClosers = append(linkClosers, link)
if link.RequireReference {
requireReference = true
}
rrf = append(rrf, rr)
}
rr := func(ctx context.Context, httpRange http_range.Range) (io.ReadCloser, error) {
return rrf[rand.Intn(len(rrf))].RangeRead(ctx, httpRange)
}
return &model.Link{
RangeReader: stream.RangeReaderFunc(rr),
SyncClosers: utils.NewSyncClosers(linkClosers...),
RequireReference: requireReference,
}, nil
}
var link *model.Link
var fi model.Obj
var err error
files := file.(BalancedObjs)
if d.ReadConflictPolicy == RandomBalancedRP || d.ReadConflictPolicy == AllRWP {
rand.Shuffle(len(files), func(i, j int) {
files[i], files[j] = files[j], files[i]
})
}
for _, f := range files {
if f == nil {
continue
}
link, fi, err = d.link(ctx, f.GetPath(), args)
if err == nil {
if link == nil {
// 重定向且需要通过代理
return &model.Link{
URL: fmt.Sprintf("%s/p%s?sign=%s",
common.GetApiUrl(ctx),
utils.EncodePath(f.GetPath(), true),
sign.Sign(f.GetPath())),
}, nil
}
break
if link == nil {
// 重定向且需要通过代理
return &model.Link{
URL: fmt.Sprintf("%s/p%s?sign=%s",
common.GetApiUrl(ctx),
utils.EncodePath(reqPath, true),
sign.Sign(reqPath)),
}, nil
}
resultLink := *link
resultLink.SyncClosers = utils.NewSyncClosers(link)
if args.Redirect {
return &resultLink, nil
}
if resultLink.ContentLength == 0 {
resultLink.ContentLength = fi.GetSize()
}
if d.DownloadConcurrency > 0 {
resultLink.Concurrency = d.DownloadConcurrency
}
if d.DownloadPartSize > 0 {
resultLink.PartSize = d.DownloadPartSize * utils.KB
}
return &resultLink, nil
}
if err != nil {
return nil, err
}
resultLink := link.Clone() // 复制一份,避免修改到原始link
resultLink.Expiration = nil
if args.Redirect {
return resultLink, nil
}
if d.DownloadConcurrency > 0 {
resultLink.Concurrency = d.DownloadConcurrency
}
if d.DownloadPartSize > 0 {
resultLink.PartSize = d.DownloadPartSize * utils.KB
}
if resultLink.ContentLength == 0 {
resultLink.ContentLength = fi.GetSize()
}
return resultLink, nil
return nil, errs.ObjectNotFound
}
func (d *Alias) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
// Other 不应负载均衡,这是因为前端是否调用 /fs/other 的判断条件是返回的 provider 的值
// 而 ProviderPassThrough 开启时,返回的 provider 固定为第一个 obj 的后端驱动
storage, actualPath, err := op.GetStorageAndActualPath(args.Obj.GetPath())
if err != nil {
return nil, err
root, sub := d.getRootAndPath(args.Obj.GetPath())
dsts, ok := d.pathMap[root]
if !ok {
return nil, errs.ObjectNotFound
}
return op.Other(ctx, storage, model.FsOtherArgs{
Path: actualPath,
Method: args.Method,
Data: args.Data,
})
for _, dst := range dsts {
rawPath := stdpath.Join(dst, sub)
storage, actualPath, err := op.GetStorageAndActualPath(rawPath)
if err != nil {
continue
}
other, ok := storage.(driver.Other)
if !ok {
continue
}
obj, err := op.GetUnwrap(ctx, storage, actualPath)
if err != nil {
continue
}
return other.Other(ctx, model.OtherArgs{
Obj: obj,
Method: args.Method,
Data: args.Data,
})
}
return nil, errs.NotImplement
}
func (d *Alias) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
objs, err := d.getWriteObjs(ctx, parentDir)
if !d.Writable {
return errs.PermissionDenied
}
reqPath, err := d.getReqPath(ctx, parentDir, true)
if err == nil {
for _, obj := range objs {
err = errors.Join(err, fs.MakeDir(ctx, stdpath.Join(obj.GetPath(), dirName)))
for _, path := range reqPath {
err = errors.Join(err, fs.MakeDir(ctx, stdpath.Join(*path, dirName)))
}
return err
}
if errs.IsNotImplementError(err) {
return errors.New("same-name dirs cannot make sub-dir")
}
return err
}
func (d *Alias) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
srcs, dsts, err := d.getMoveObjs(ctx, srcObj, dstDir)
if err == nil {
for i, dst := range dsts {
src := srcs[i]
_, e := fs.Move(ctx, src.GetPath(), dst.GetPath())
err = errors.Join(err, e)
}
srcs = srcs[len(dsts):]
for _, src := range srcs {
e := fs.Remove(ctx, src.GetPath())
err = errors.Join(err, e)
}
if !d.Writable {
return errs.PermissionDenied
}
srcPath, err := d.getReqPath(ctx, srcObj, false)
if errs.IsNotImplementError(err) {
return errors.New("same-name files cannot be moved")
}
if err != nil {
return err
}
dstPath, err := d.getReqPath(ctx, dstDir, true)
if errs.IsNotImplementError(err) {
return errors.New("same-name dirs cannot be moved to")
}
if err != nil {
return err
}
if len(srcPath) == len(dstPath) {
for i := range srcPath {
_, e := fs.Move(ctx, *srcPath[i], *dstPath[i])
err = errors.Join(err, e)
}
return err
} else {
return errors.New("parallel paths mismatch")
}
return err
}
func (d *Alias) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
objs, err := d.getWriteObjs(ctx, srcObj)
if !d.Writable {
return errs.PermissionDenied
}
reqPath, err := d.getReqPath(ctx, srcObj, false)
if err == nil {
for _, obj := range objs {
err = errors.Join(err, fs.Rename(ctx, obj.GetPath(), newName))
for _, path := range reqPath {
err = errors.Join(err, fs.Rename(ctx, *path, newName))
}
return err
}
if errs.IsNotImplementError(err) {
return errors.New("same-name files cannot be Rename")
}
return err
}
func (d *Alias) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
srcs, dsts, err := d.getCopyObjs(ctx, srcObj, dstDir)
if err == nil {
for i, src := range srcs {
dst := dsts[i]
_, e := fs.Copy(ctx, src.GetPath(), dst.GetPath())
if !d.Writable {
return errs.PermissionDenied
}
srcPath, err := d.getReqPath(ctx, srcObj, false)
if errs.IsNotImplementError(err) {
return errors.New("same-name files cannot be copied")
}
if err != nil {
return err
}
dstPath, err := d.getReqPath(ctx, dstDir, true)
if errs.IsNotImplementError(err) {
return errors.New("same-name dirs cannot be copied to")
}
if err != nil {
return err
}
if len(srcPath) == len(dstPath) {
for i := range srcPath {
_, e := fs.Copy(ctx, *srcPath[i], *dstPath[i])
err = errors.Join(err, e)
}
return err
} else if len(srcPath) == 1 || !d.ProtectSameName {
for _, path := range dstPath {
_, e := fs.Copy(ctx, *srcPath[0], *path)
err = errors.Join(err, e)
}
return err
} else {
return errors.New("parallel paths mismatch")
}
return err
}
func (d *Alias) Remove(ctx context.Context, obj model.Obj) error {
objs, err := d.getWriteObjs(ctx, obj)
if !d.Writable {
return errs.PermissionDenied
}
reqPath, err := d.getReqPath(ctx, obj, false)
if err == nil {
for _, obj := range objs {
err = errors.Join(err, fs.Remove(ctx, obj.GetPath()))
for _, path := range reqPath {
err = errors.Join(err, fs.Remove(ctx, *path))
}
return err
}
if errs.IsNotImplementError(err) {
return errors.New("same-name files cannot be Delete")
}
return err
}
func (d *Alias) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) error {
objs, err := d.getPutObjs(ctx, dstDir)
if !d.Writable {
return errs.PermissionDenied
}
reqPath, err := d.getReqPath(ctx, dstDir, true)
if err == nil {
if len(objs) == 1 {
storage, reqActualPath, err := op.GetStorageAndActualPath(objs.GetPath())
if len(reqPath) == 1 {
storage, reqActualPath, err := op.GetStorageAndActualPath(*reqPath[0])
if err != nil {
return err
}
@@ -435,10 +387,10 @@ func (d *Alias) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer,
if err != nil {
return err
}
count := float64(len(objs) + 1)
count := float64(len(reqPath) + 1)
up(100 / count)
for i, obj := range objs {
err = errors.Join(err, fs.PutDirectly(ctx, obj.GetPath(), &stream.FileStream{
for i, path := range reqPath {
err = errors.Join(err, fs.PutDirectly(ctx, *path, &stream.FileStream{
Obj: s,
Mimetype: s.GetMimetype(),
Reader: file,
@@ -452,40 +404,55 @@ func (d *Alias) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer,
return err
}
}
if errs.IsNotImplementError(err) {
return errors.New("same-name dirs cannot be Put")
}
return err
}
func (d *Alias) PutURL(ctx context.Context, dstDir model.Obj, name, url string) error {
objs, err := d.getPutObjs(ctx, dstDir)
if !d.Writable {
return errs.PermissionDenied
}
reqPath, err := d.getReqPath(ctx, dstDir, true)
if err == nil {
for _, obj := range objs {
err = errors.Join(err, fs.PutURL(ctx, obj.GetPath(), name, url))
for _, path := range reqPath {
err = errors.Join(err, fs.PutURL(ctx, *path, name, url))
}
return err
}
if errs.IsNotImplementError(err) {
return errors.New("same-name files cannot offline download")
}
return err
}
func (d *Alias) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) {
reqPath := d.getBalancedPath(ctx, obj)
if reqPath == "" {
return nil, errs.NotFile
root, sub := d.getRootAndPath(obj.GetPath())
dsts, ok := d.pathMap[root]
if !ok {
return nil, errs.ObjectNotFound
}
meta, err := d.getArchiveMeta(ctx, reqPath, args)
if err == nil {
return meta, nil
for _, dst := range dsts {
meta, err := d.getArchiveMeta(ctx, dst, sub, args)
if err == nil {
return meta, nil
}
}
return nil, errs.NotImplement
}
func (d *Alias) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) {
reqPath := d.getBalancedPath(ctx, obj)
if reqPath == "" {
return nil, errs.NotFile
root, sub := d.getRootAndPath(obj.GetPath())
dsts, ok := d.pathMap[root]
if !ok {
return nil, errs.ObjectNotFound
}
l, err := d.listArchive(ctx, reqPath, args)
if err == nil {
return l, nil
for _, dst := range dsts {
l, err := d.listArchive(ctx, dst, sub, args)
if err == nil {
return l, nil
}
}
return nil, errs.NotImplement
}
@@ -494,76 +461,78 @@ func (d *Alias) Extract(ctx context.Context, obj model.Obj, args model.ArchiveIn
// alias的两个驱动,一个支持驱动提取,一个不支持,如何兼容?
// 如果访问的是不支持驱动提取的驱动内的压缩文件,GetArchiveMeta就会返回errs.NotImplement,提取URL前缀就会是/ae,Extract就不会被调用
// 如果访问的是支持驱动提取的驱动内的压缩文件,GetArchiveMeta就会返回有效值,提取URL前缀就会是/ad,Extract就会被调用
reqPath := d.getBalancedPath(ctx, obj)
if reqPath == "" {
return nil, errs.NotFile
root, sub := d.getRootAndPath(obj.GetPath())
dsts, ok := d.pathMap[root]
if !ok {
return nil, errs.ObjectNotFound
}
link, err := d.extract(ctx, reqPath, args)
if err != nil {
return nil, errs.NotImplement
}
if link == nil {
return &model.Link{
URL: fmt.Sprintf("%s/ap%s?inner=%s&pass=%s&sign=%s",
common.GetApiUrl(ctx),
utils.EncodePath(reqPath, true),
utils.EncodePath(args.InnerPath, true),
url.QueryEscape(args.Password),
sign.SignArchive(reqPath)),
}, nil
}
return link.Clone(), nil
}
func (d *Alias) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) error {
srcs, dsts, err := d.getCopyObjs(ctx, srcObj, dstDir)
if err == nil {
for i, src := range srcs {
dst := dsts[i]
_, e := fs.ArchiveDecompress(ctx, src.GetPath(), dst.GetPath(), args)
err = errors.Join(err, e)
}
}
return err
}
func (d *Alias) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
if !d.DetailsPassThrough {
return nil, errs.NotImplement
}
if len(d.rootOrder) != 1 {
return nil, errs.NotImplement
}
backends := d.pathMap[d.rootOrder[0]]
var storage driver.Driver
for _, backend := range backends {
s, err := fs.GetStorage(backend, &fs.GetStoragesArgs{})
if err != nil {
return nil, errs.NotImplement
}
if storage == nil {
storage = s
} else if storage.GetStorage().MountPath != s.GetStorage().MountPath {
return nil, errs.NotImplement
}
}
if storage == nil { // should never access
return nil, errs.NotImplement
}
return op.GetStorageDetails(ctx, storage)
}
func (d *Alias) ResolveLinkCacheMode(path string) driver.LinkCacheMode {
roots, sub := d.getRootsAndPath(path)
if len(roots) == 0 {
return 0
}
for _, root := range roots {
storage, actualPath, err := op.GetStorageAndActualPath(stdpath.Join(root, sub))
for _, dst := range dsts {
reqPath := stdpath.Join(dst, sub)
link, err := d.extract(ctx, reqPath, args)
if err != nil {
continue
}
if storage.Config().CheckStatus && storage.GetStorage().Status != op.WORK {
if link == nil {
return &model.Link{
URL: fmt.Sprintf("%s/ap%s?inner=%s&pass=%s&sign=%s",
common.GetApiUrl(ctx),
utils.EncodePath(reqPath, true),
utils.EncodePath(args.InnerPath, true),
url.QueryEscape(args.Password),
sign.SignArchive(reqPath)),
}, nil
}
resultLink := *link
resultLink.SyncClosers = utils.NewSyncClosers(link)
return &resultLink, nil
}
return nil, errs.NotImplement
}
func (d *Alias) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) error {
if !d.Writable {
return errs.PermissionDenied
}
srcPath, err := d.getReqPath(ctx, srcObj, false)
if errs.IsNotImplementError(err) {
return errors.New("same-name files cannot be decompressed")
}
if err != nil {
return err
}
dstPath, err := d.getReqPath(ctx, dstDir, true)
if errs.IsNotImplementError(err) {
return errors.New("same-name dirs cannot be decompressed to")
}
if err != nil {
return err
}
if len(srcPath) == len(dstPath) {
for i := range srcPath {
_, e := fs.ArchiveDecompress(ctx, *srcPath[i], *dstPath[i], args)
err = errors.Join(err, e)
}
return err
} else if len(srcPath) == 1 || !d.ProtectSameName {
for _, path := range dstPath {
_, e := fs.ArchiveDecompress(ctx, *srcPath[0], *path, args)
err = errors.Join(err, e)
}
return err
} else {
return errors.New("parallel paths mismatch")
}
}
func (d *Alias) ResolveLinkCacheMode(path string) driver.LinkCacheMode {
root, sub := d.getRootAndPath(path)
dsts, ok := d.pathMap[root]
if !ok {
return 0
}
for _, dst := range dsts {
storage, actualPath, err := op.GetStorageAndActualPath(stdpath.Join(dst, sub))
if err != nil {
continue
}
mode := storage.Config().LinkCacheMode
+16 -10
View File
@@ -6,15 +6,17 @@ import (
)
type Addition struct {
Paths string `json:"paths" required:"true" type:"text"`
ReadConflictPolicy string `json:"read_conflict_policy" type:"select" options:"first,random,all" default:"first"`
WriteConflictPolicy string `json:"write_conflict_policy" type:"select" options:"disabled,first,deterministic,deterministic_or_all,all,all_strict" default:"disabled" help:"How the driver handles identical backend paths when renaming, removing, or making directories."`
PutConflictPolicy string `json:"put_conflict_policy" type:"select" options:"disabled,first,deterministic,deterministic_or_all,all,all_strict,random,quota,quota_strict" default:"disabled" help:"How the driver handles identical backend paths when uploading, copying, moving, or decompressing."`
FileConsistencyCheck bool `json:"file_consistency_check" type:"bool" default:"false"`
DownloadConcurrency int `json:"download_concurrency" default:"0" required:"false" type:"number" help:"Need to enable proxy"`
DownloadPartSize int `json:"download_part_size" default:"0" type:"number" required:"false" help:"Need to enable proxy. Unit: KB"`
ProviderPassThrough bool `json:"provider_pass_through" type:"bool" default:"false"`
DetailsPassThrough bool `json:"details_pass_through" type:"bool" default:"false"`
// Usually one of two
// driver.RootPath
// define other
Paths string `json:"paths" required:"true" type:"text"`
ProtectSameName bool `json:"protect_same_name" default:"true" required:"false" help:"Protects same-name files from Delete or Rename"`
ParallelWrite bool `json:"parallel_write" type:"bool" default:"false"`
DownloadConcurrency int `json:"download_concurrency" default:"0" required:"false" type:"number" help:"Need to enable proxy"`
DownloadPartSize int `json:"download_part_size" default:"0" type:"number" required:"false" help:"Need to enable proxy. Unit: KB"`
Writable bool `json:"writable" type:"bool" default:"false"`
ProviderPassThrough bool `json:"provider_pass_through" type:"bool" default:"false"`
DetailsPassThrough bool `json:"details_pass_through" type:"bool" default:"false"`
}
var config = driver.Config{
@@ -29,6 +31,10 @@ var config = driver.Config{
func init() {
op.RegisterDriver(func() driver.Driver {
return &Alias{}
return &Alias{
Addition: Addition{
ProtectSameName: true,
},
}
})
}
-77
View File
@@ -1,78 +1 @@
package alias
import (
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/pkg/errors"
)
const (
DisabledWP = "disabled"
FirstRWP = "first"
DeterministicWP = "deterministic"
DeterministicOrAllWP = "deterministic_or_all"
AllRWP = "all"
AllStrictWP = "all_strict"
RandomBalancedRP = "random"
BalancedByQuotaP = "quota"
BalancedByQuotaStrictP = "quota_strict"
)
var (
ValidReadConflictPolicy = []string{FirstRWP, RandomBalancedRP, AllRWP}
ValidWriteConflictPolicy = []string{DisabledWP, FirstRWP, DeterministicWP, DeterministicOrAllWP, AllRWP,
AllStrictWP}
ValidPutConflictPolicy = []string{DisabledWP, FirstRWP, DeterministicWP, DeterministicOrAllWP, AllRWP,
AllStrictWP, RandomBalancedRP, BalancedByQuotaP, BalancedByQuotaStrictP}
)
var (
ErrPathConflict = errors.New("path conflict")
ErrSamePathLeak = errors.New("leak some of same-name dirs")
ErrNoEnoughSpace = errors.New("none of same-name dirs has enough space")
ErrNotEnoughSrcObjs = errors.New("cannot move fewer objs to more paths, please try copying")
)
type BalancedObjs []model.Obj
func (b BalancedObjs) GetSize() int64 {
return b[0].GetSize()
}
func (b BalancedObjs) ModTime() time.Time {
return b[0].ModTime()
}
func (b BalancedObjs) CreateTime() time.Time {
return b[0].CreateTime()
}
func (b BalancedObjs) IsDir() bool {
return b[0].IsDir()
}
func (b BalancedObjs) GetHash() utils.HashInfo {
return b[0].GetHash()
}
func (b BalancedObjs) GetName() string {
return b[0].GetName()
}
func (b BalancedObjs) GetPath() string {
return b[0].GetPath()
}
func (b BalancedObjs) GetID() string {
return b[0].GetID()
}
func (b BalancedObjs) Unwrap() model.Obj {
return b[0]
}
var _ model.Obj = (BalancedObjs)(nil)
type tempObj struct{ model.Object }
+71 -407
View File
@@ -2,9 +2,10 @@ package alias
import (
"context"
"math/rand"
"errors"
stdpath "path"
"strings"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
@@ -13,29 +14,20 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
type detailWithIndex struct {
idx int
val *model.StorageDetails
}
func (d *Alias) listRoot(ctx context.Context, withDetails, refresh bool) []model.Obj {
var objs []model.Obj
detailsChan := make(chan detailWithIndex, len(d.pathMap))
workerCount := 0
var wg sync.WaitGroup
for _, k := range d.rootOrder {
obj := &model.Object{
obj := model.Object{
Name: k,
Path: "/" + k,
IsFolder: true,
Modified: d.Modified,
Mask: model.Locked | model.Virtual,
}
idx := len(objs)
objs = append(objs, obj)
objs = append(objs, &obj)
v := d.pathMap[k]
if !withDetails || len(v) != 1 {
continue
@@ -44,56 +36,58 @@ func (d *Alias) listRoot(ctx context.Context, withDetails, refresh bool) []model
if err != nil {
continue
}
obj.Modified = remoteDriver.GetStorage().Modified
_, ok := remoteDriver.(driver.WithDetails)
if !ok {
continue
}
objs[idx] = &model.ObjStorageDetails{
Obj: objs[idx],
StorageDetails: nil,
Obj: objs[idx],
StorageDetailsWithName: model.StorageDetailsWithName{
StorageDetails: nil,
DriverName: remoteDriver.Config().Name,
},
}
workerCount++
go func(dri driver.Driver, i int) {
details, e := op.GetStorageDetails(ctx, dri, refresh)
wg.Add(1)
go func() {
defer wg.Done()
c, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
details, e := op.GetStorageDetails(c, remoteDriver, refresh)
if e != nil {
if !errors.Is(e, errs.NotImplement) && !errors.Is(e, errs.StorageNotInit) {
log.Errorf("failed get %s storage details: %+v", dri.GetStorage().MountPath, e)
log.Errorf("failed get %s storage details: %+v", remoteDriver.GetStorage().MountPath, e)
}
return
}
detailsChan <- detailWithIndex{idx: i, val: details}
}(remoteDriver, idx)
}
for workerCount > 0 {
select {
case r := <-detailsChan:
objs[r.idx].(*model.ObjStorageDetails).StorageDetails = r.val
workerCount--
case <-time.After(time.Second):
workerCount = 0
}
objs[idx].(*model.ObjStorageDetails).StorageDetails = details
}()
}
wg.Wait()
return objs
}
// do others that not defined in Driver interface
func getPair(path string) (string, string) {
if name, path, ok := strings.Cut(path, ":"); ok && !strings.Contains(name, "/") {
return name, path
// path = strings.TrimSpace(path)
if strings.Contains(path, ":") {
pair := strings.SplitN(path, ":", 2)
if !strings.Contains(pair[0], "/") {
return pair[0], pair[1]
}
}
return stdpath.Base(path), path
}
func (d *Alias) getRootsAndPath(path string) (roots []string, sub string) {
if len(d.rootOrder) == 1 {
return d.pathMap[d.rootOrder[0]], path
func (d *Alias) getRootAndPath(path string) (string, string) {
if d.autoFlatten {
return d.oneKey, path
}
path = strings.TrimPrefix(path, "/")
before, after, ok := strings.Cut(path, "/")
if !ok {
return d.pathMap[path], ""
parts := strings.SplitN(path, "/", 2)
if len(parts) == 1 {
return parts[0], ""
}
return d.pathMap[before], after
return parts[0], parts[1]
}
func (d *Alias) link(ctx context.Context, reqPath string, args model.LinkArgs) (*model.Link, model.Obj, error) {
@@ -101,350 +95,59 @@ func (d *Alias) link(ctx context.Context, reqPath string, args model.LinkArgs) (
if err != nil {
return nil, nil, err
}
if args.Redirect && common.ShouldProxy(storage, stdpath.Base(reqPath)) {
return nil, nil, nil
if !args.Redirect {
return op.Link(ctx, storage, reqActualPath, args)
}
obj, err := fs.Get(ctx, reqPath, &fs.GetArgs{NoLog: true})
if err != nil {
return nil, nil, err
}
if common.ShouldProxy(storage, stdpath.Base(reqPath)) {
return nil, obj, nil
}
return op.Link(ctx, storage, reqActualPath, args)
}
func isConsistent(a, b model.Obj) bool {
if a.GetSize() != b.GetSize() {
return false
func (d *Alias) getReqPath(ctx context.Context, obj model.Obj, isParent bool) ([]*string, error) {
root, sub := d.getRootAndPath(obj.GetPath())
if sub == "" && !isParent {
return nil, errs.NotSupport
}
for ht, v := range a.GetHash().All() {
ah := b.GetHash().GetHash(ht)
if ah != "" && ah != v {
return false
}
}
return true
}
func (d *Alias) getAllObjs(ctx context.Context, bObj model.Obj, ifContinue func(err error) (bool, error)) (BalancedObjs, error) {
objs := bObj.(BalancedObjs)
length := 0
for _, o := range objs {
var err error
var obj model.Obj
temp, isTemp := o.(*tempObj)
if isTemp {
obj, err = fs.Get(ctx, o.GetPath(), &fs.GetArgs{NoLog: true})
if err == nil {
if !bObj.IsDir() {
if obj.IsDir() {
err = errs.NotFile
} else if d.FileConsistencyCheck && !isConsistent(bObj, obj) {
err = errs.ObjectNotFound
}
} else if !obj.IsDir() {
err = errs.NotFolder
}
}
} else if o == nil {
err = errs.ObjectNotFound
}
cont, err := ifContinue(err)
if err != nil {
if cont {
continue
}
return nil, err
}
if isTemp {
objRes := temp.Object
// objRes.Name = obj.GetName()
// objRes.Size = obj.GetSize()
// objRes.Modified = obj.ModTime()
// objRes.HashInfo = obj.GetHash()
objs[length] = &objRes
} else {
objs[length] = o
}
length++
if !cont {
break
}
}
if length == 0 {
dsts, ok := d.pathMap[root]
all := true
if !ok {
return nil, errs.ObjectNotFound
}
return objs[:length], nil
}
func (d *Alias) getBalancedPath(ctx context.Context, file model.Obj) string {
if d.ReadConflictPolicy == FirstRWP {
return file.GetPath()
}
files := file.(BalancedObjs)
if rand.Intn(len(files)) == 0 {
return file.GetPath()
}
files, _ = d.getAllObjs(ctx, file, getWriteAndPutFilterFunc(AllRWP))
return files[rand.Intn(len(files))].GetPath()
}
func getWriteAndPutFilterFunc(policy string) func(error) (bool, error) {
if policy == AllRWP {
return func(err error) (bool, error) {
return true, err
}
}
all := true
l := 0
return func(err error) (bool, error) {
var reqPath []*string
for _, dst := range dsts {
path := stdpath.Join(dst, sub)
_, err := fs.Get(ctx, path, &fs.GetArgs{NoLog: true})
if err != nil {
switch policy {
case AllStrictWP:
return false, ErrSamePathLeak
case DeterministicOrAllWP:
if l >= 2 {
return false, ErrSamePathLeak
}
}
all = false
} else {
switch policy {
case FirstRWP:
return false, nil
case DeterministicWP:
if l > 0 {
return false, ErrPathConflict
}
case DeterministicOrAllWP:
if l > 0 && !all {
return false, ErrSamePathLeak
}
if d.ProtectSameName && d.ParallelWrite && len(reqPath) >= 2 {
return nil, errs.NotImplement
}
l += 1
}
return true, err
}
}
func (d *Alias) getWriteObjs(ctx context.Context, obj model.Obj) (BalancedObjs, error) {
if d.WriteConflictPolicy == DisabledWP {
return nil, errs.PermissionDenied
}
return d.getAllObjs(ctx, obj, getWriteAndPutFilterFunc(d.WriteConflictPolicy))
}
func (d *Alias) getPutObjs(ctx context.Context, obj model.Obj) (BalancedObjs, error) {
if d.PutConflictPolicy == DisabledWP {
return nil, errs.PermissionDenied
}
objs, err := d.getAllObjs(ctx, obj, getWriteAndPutFilterFunc(d.PutConflictPolicy))
if err != nil {
return nil, err
}
strict := false
switch d.PutConflictPolicy {
case RandomBalancedRP:
ri := rand.Intn(len(objs))
return objs[ri : ri+1], nil
case BalancedByQuotaStrictP:
strict = true
fallthrough
case BalancedByQuotaP:
objs, ok := getRandomObjByQuotaBalanced(ctx, objs, strict, obj.GetSize())
if !ok {
return nil, ErrNoEnoughSpace
}
return objs, nil
default:
return objs, nil
}
}
func getRandomObjByQuotaBalanced(ctx context.Context, reqPath BalancedObjs, strict bool, objSize int64) (BalancedObjs, bool) {
// Get all space
details := make([]*model.StorageDetails, len(reqPath))
detailsChan := make(chan detailWithIndex, len(reqPath))
workerCount := 0
for i, p := range reqPath {
s, err := fs.GetStorage(p.GetPath(), &fs.GetStoragesArgs{})
if err != nil {
continue
}
if _, ok := s.(driver.WithDetails); !ok {
continue
if !d.ProtectSameName && !d.ParallelWrite {
return []*string{&path}, nil
}
workerCount++
go func(dri driver.Driver, i int) {
d, e := op.GetStorageDetails(ctx, dri)
if e != nil {
if !errors.Is(e, errs.NotImplement) && !errors.Is(e, errs.StorageNotInit) {
log.Errorf("failed get %s storage details: %+v", dri.GetStorage().MountPath, e)
}
}
detailsChan <- detailWithIndex{idx: i, val: d}
}(s, i)
}
for workerCount > 0 {
select {
case r := <-detailsChan:
details[r.idx] = r.val
workerCount--
case <-time.After(time.Second):
workerCount = 0
reqPath = append(reqPath, &path)
if d.ProtectSameName && !d.ParallelWrite && len(reqPath) >= 2 {
return nil, errs.NotImplement
}
if d.ProtectSameName && d.ParallelWrite && len(reqPath) >= 2 && !all {
return nil, errs.NotImplement
}
}
// Try select one that has space info
selected, ok := selectRandom(details, func(d *model.StorageDetails) uint64 {
if d == nil || d.FreeSpace() < objSize {
return 0
}
return uint64(d.FreeSpace())
})
if !ok {
if strict {
return nil, false
} else {
// No strict mode, return any of non-details ones
noDetails := make([]int, 0, len(details))
for i, d := range details {
if d == nil {
noDetails = append(noDetails, i)
}
}
if len(noDetails) == 0 {
return nil, false
}
selected = noDetails[rand.Intn(len(noDetails))]
}
if len(reqPath) == 0 {
return nil, errs.ObjectNotFound
}
return reqPath[selected : selected+1], true
return reqPath, nil
}
func selectRandom[Item any](arr []Item, getWeight func(Item) uint64) (int, bool) {
var totalWeight uint64 = 0
for _, i := range arr {
totalWeight += getWeight(i)
}
if totalWeight == 0 {
return 0, false
}
r := rand.Uint64() % totalWeight
for i, item := range arr {
w := getWeight(item)
if r < w {
return i, true
}
r -= w
}
return 0, false
}
func (d *Alias) getCopyObjs(ctx context.Context, srcObj, dstDir model.Obj) (BalancedObjs, BalancedObjs, error) {
if d.PutConflictPolicy == DisabledWP {
return nil, nil, errs.PermissionDenied
}
dstObjs, err := d.getAllObjs(ctx, dstDir, getWriteAndPutFilterFunc(d.PutConflictPolicy))
if err != nil {
return nil, nil, err
}
dstStorageMap := make(map[string][]model.Obj)
allocatingDst := make(map[model.Obj]struct{})
for _, o := range dstObjs {
storage, e := fs.GetStorage(o.GetPath(), &fs.GetStoragesArgs{})
if e != nil {
return nil, nil, errors.WithMessagef(e, "cannot copy to virtual path [%s]", o.GetPath())
}
mp := storage.GetStorage().MountPath
dstStorageMap[mp] = append(dstStorageMap[mp], o)
allocatingDst[o] = struct{}{}
}
tmpSrcObjs, err := d.getAllObjs(ctx, srcObj, getWriteAndPutFilterFunc(AllRWP))
if err != nil {
return nil, nil, err
}
srcObjs := make(BalancedObjs, 0, len(dstObjs))
for _, src := range tmpSrcObjs {
storage, e := fs.GetStorage(src.GetPath(), &fs.GetStoragesArgs{})
if e != nil {
continue
}
mp := storage.GetStorage().MountPath
if tmp, ok := dstStorageMap[mp]; ok {
for _, dst := range tmp {
dstObjs[len(srcObjs)] = dst
srcObjs = append(srcObjs, src)
delete(allocatingDst, dst)
}
delete(dstStorageMap, mp)
}
}
dstObjs = dstObjs[:len(srcObjs)]
for dst := range allocatingDst {
src := tmpSrcObjs[0]
if d.ReadConflictPolicy == RandomBalancedRP || d.ReadConflictPolicy == AllRWP {
src = tmpSrcObjs[rand.Intn(len(tmpSrcObjs))]
}
srcObjs = append(srcObjs, src)
dstObjs = append(dstObjs, dst)
}
return srcObjs, dstObjs, nil
}
func (d *Alias) getMoveObjs(ctx context.Context, srcObj, dstDir model.Obj) (BalancedObjs, BalancedObjs, error) {
if d.PutConflictPolicy == DisabledWP {
return nil, nil, errs.PermissionDenied
}
dstObjs, err := d.getAllObjs(ctx, dstDir, getWriteAndPutFilterFunc(d.PutConflictPolicy))
if err != nil {
return nil, nil, err
}
tmpSrcObjs, err := d.getAllObjs(ctx, srcObj, getWriteAndPutFilterFunc(AllRWP))
if err != nil {
return nil, nil, err
}
if len(tmpSrcObjs) < len(dstObjs) {
return nil, nil, ErrNotEnoughSrcObjs
}
dstStorageMap := make(map[string][]model.Obj)
allocatingDst := make(map[model.Obj]struct{})
for _, o := range dstObjs {
storage, e := fs.GetStorage(o.GetPath(), &fs.GetStoragesArgs{})
if e != nil {
return nil, nil, errors.WithMessagef(e, "cannot move to virtual path [%s]", o.GetPath())
}
mp := storage.GetStorage().MountPath
dstStorageMap[mp] = append(dstStorageMap[mp], o)
allocatingDst[o] = struct{}{}
}
srcObjs := make(BalancedObjs, 0, len(tmpSrcObjs))
restSrcObjs := make(BalancedObjs, 0, len(tmpSrcObjs)-len(dstObjs))
for _, src := range tmpSrcObjs {
storage, e := fs.GetStorage(src.GetPath(), &fs.GetStoragesArgs{})
if e != nil {
continue
}
mp := storage.GetStorage().MountPath
if tmp, ok := dstStorageMap[mp]; ok {
dst := tmp[0]
if len(tmp) == 1 {
delete(dstStorageMap, mp)
} else {
dstStorageMap[mp] = tmp[1:]
}
dstObjs[len(srcObjs)] = dst
srcObjs = append(srcObjs, src)
delete(allocatingDst, dst)
} else {
restSrcObjs = append(restSrcObjs, src)
}
}
dstObjs = dstObjs[:len(srcObjs)]
// len(restSrcObjs) >= len(allocatingDst)
srcObjs = append(srcObjs, restSrcObjs...)
for dst := range allocatingDst {
dstObjs = append(dstObjs, dst)
}
return srcObjs, dstObjs, nil
}
func (d *Alias) getArchiveMeta(ctx context.Context, reqPath string, args model.ArchiveArgs) (model.ArchiveMeta, error) {
func (d *Alias) getArchiveMeta(ctx context.Context, dst, sub string, args model.ArchiveArgs) (model.ArchiveMeta, error) {
reqPath := stdpath.Join(dst, sub)
storage, reqActualPath, err := op.GetStorageAndActualPath(reqPath)
if err != nil {
return nil, err
@@ -458,7 +161,8 @@ func (d *Alias) getArchiveMeta(ctx context.Context, reqPath string, args model.A
return nil, errs.NotImplement
}
func (d *Alias) listArchive(ctx context.Context, reqPath string, args model.ArchiveInnerArgs) ([]model.Obj, error) {
func (d *Alias) listArchive(ctx context.Context, dst, sub string, args model.ArchiveInnerArgs) ([]model.Obj, error) {
reqPath := stdpath.Join(dst, sub)
storage, reqActualPath, err := op.GetStorageAndActualPath(reqPath)
if err != nil {
return nil, err
@@ -490,43 +194,3 @@ func (d *Alias) extract(ctx context.Context, reqPath string, args model.ArchiveI
link, _, err := op.DriverExtract(ctx, storage, reqActualPath, args)
return link, err
}
func getAllSort(dirs []model.Obj) model.Sort {
ret := model.Sort{}
noSort := false
noExtractFolder := false
for _, dir := range dirs {
if dir == nil {
continue
}
storage, err := fs.GetStorage(dir.GetPath(), &fs.GetStoragesArgs{})
if err != nil {
continue
}
if !noSort && storage.GetStorage().OrderBy != "" {
if ret.OrderBy == "" {
ret.OrderBy = storage.GetStorage().OrderBy
ret.OrderDirection = storage.GetStorage().OrderDirection
if ret.OrderDirection == "" {
ret.OrderDirection = "asc"
}
} else if ret.OrderBy != storage.GetStorage().OrderBy || ret.OrderDirection != storage.GetStorage().OrderDirection {
ret.OrderBy = ""
ret.OrderDirection = ""
noSort = true
}
}
if !noExtractFolder && storage.GetStorage().ExtractFolder != "" {
if ret.ExtractFolder == "" {
ret.ExtractFolder = storage.GetStorage().ExtractFolder
} else if ret.ExtractFolder != storage.GetStorage().ExtractFolder {
ret.ExtractFolder = ""
noExtractFolder = true
}
}
if noSort && noExtractFolder {
break
}
}
return ret
}
-147
View File
@@ -1,147 +0,0 @@
package alidoc
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/go-resty/resty/v2"
)
type AliDoc struct {
model.Storage
Addition
client *resty.Client
}
func (d *AliDoc) Config() driver.Config {
return config
}
func (d *AliDoc) GetAddition() driver.Additional {
return &d.Addition
}
func (d *AliDoc) Init(ctx context.Context) error {
d.Cookie = strings.TrimSpace(d.Cookie)
d.RootFolderID = strings.TrimSpace(d.RootFolderID)
if d.Cookie == "" {
return fmt.Errorf("cookie is empty")
}
if d.RootFolderID == "" {
return fmt.Errorf("root folder id is empty")
}
d.client = newClient()
if err := d.checkCookie(ctx); err != nil {
return err
}
return nil
}
func (d *AliDoc) Drop(ctx context.Context) error {
d.client = nil
return nil
}
func (d *AliDoc) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
items, err := d.list(ctx, dir.GetID())
if err != nil {
return nil, err
}
objs := make([]model.Obj, 0, len(items))
for _, item := range items {
if strings.TrimSpace(item.DentryUUID) == "" || strings.TrimSpace(item.Name) == "" {
continue
}
objs = append(objs, toObj(item))
}
return objs, nil
}
func (d *AliDoc) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if file.IsDir() {
return nil, fmt.Errorf("alidoc does not support directory links")
}
resp, err := d.download(ctx, file.GetID())
if err != nil {
return nil, err
}
url, err := firstDownloadURL(resp)
if err != nil {
return nil, err
}
return &model.Link{
URL: url,
Header: http.Header{
"User-Agent": []string{base.UserAgent},
"Referer": []string{apiBase + "/"},
},
}, nil
}
func (d *AliDoc) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
err := d.post(ctx, "/box/api/v2/dentry/createfolder", map[string]string{
"dentryType": "folder",
"name": dirName,
"parentDentryUuid": parentDir.GetID(),
"conflictHandleStrategy": "auto_rename",
})
if err != nil {
return nil, err
}
return nil, nil
}
func (d *AliDoc) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
err := d.post(ctx, "/box/api/v2/dentry/move", map[string]interface{}{
"targetParentDentryUuid": dstDir.GetID(),
"sourceDentryUuid": srcObj.GetID(),
"operateFrom": 1,
})
if err != nil {
return nil, err
}
return srcObj, nil
}
func (d *AliDoc) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
err := d.post(ctx, "/box/api/v2/dentry/rename", map[string]string{
"dentryUuid": srcObj.GetID(),
"name": newName,
})
if err != nil {
return nil, err
}
srcObj.(*model.Object).Name = newName
return srcObj, nil
}
func (d *AliDoc) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
return d.post(ctx, "/box/api/v2/dentry/copy", map[string]interface{}{
"sourceDentryUuid": srcObj.GetID(),
"targetParentDentryUuid": dstDir.GetID(),
"operateFrom": 1,
"onlyCopyMeta": false,
})
}
func (d *AliDoc) Remove(ctx context.Context, obj model.Obj) error {
return d.post(ctx, "/box/api/v1/dentry/recycle", map[string]string{
"dentryUuid": obj.GetID(),
})
}
var (
_ driver.Driver = (*AliDoc)(nil)
_ driver.MkdirResult = (*AliDoc)(nil)
_ driver.MoveResult = (*AliDoc)(nil)
_ driver.RenameResult = (*AliDoc)(nil)
_ driver.Copy = (*AliDoc)(nil)
_ driver.Remove = (*AliDoc)(nil)
)
-23
View File
@@ -1,23 +0,0 @@
package alidoc
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
type Addition struct {
driver.RootID
Cookie string `json:"cookie" type:"text" required:"true" help:"钉钉文档网页 Cookie"`
}
var config = driver.Config{
Name: "AliDoc",
LocalSort: true,
DefaultRoot: "",
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &AliDoc{}
})
}
-87
View File
@@ -1,87 +0,0 @@
package alidoc
type apiResp struct {
Status int `json:"status"`
IsSuccess bool `json:"isSuccess"`
Message string `json:"message"`
Msg string `json:"msg"`
}
func (r apiResp) ErrMessage() string {
if r.Message != "" {
return r.Message
}
if r.Msg != "" {
return r.Msg
}
return ""
}
type listResp struct {
apiResp
Data listData `json:"data"`
}
type listData struct {
Children []dentry `json:"children"`
}
type dentry struct {
DentryType string `json:"dentryType"`
DentryUUID string `json:"dentryUuid"`
ParentDentryUUID string `json:"parentDentryUuid"`
Name string `json:"name"`
Path string `json:"path"`
FileSize int64 `json:"fileSize"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
ContentType string `json:"contentType"`
Extension string `json:"extension"`
DentryStatistic struct {
ChildrenCount int `json:"childrenCount"`
} `json:"dentryStatistic"`
URL struct {
PCChildAppPreviewURL string `json:"pcChildAppPreviewUrl"`
PCChildAppURL string `json:"pcChildAppUrl"`
} `json:"url"`
}
type downloadResp struct {
apiResp
Data downloadData `json:"data"`
}
type downloadData struct {
OSSURLPreSignatureInfo struct {
PreSignURLs []string `json:"preSignUrls"`
} `json:"ossUrlPreSignatureInfo"`
}
type uploadInfoResp struct {
apiResp
Data uploadInfoData `json:"data"`
}
type uploadInfoData struct {
CurrentTimestamp int64 `json:"currentTimestamp"`
FileUploadProtocolConfig uploadProtocolConfig `json:"fileUploadProtocolConfig"`
STSSignatureInfo uploadSTSSignatureInfo `json:"stsSignatureInfo"`
UploadKey string `json:"uploadKey"`
UploadType string `json:"uploadType"`
}
type uploadProtocolConfig struct {
MinPartSize int64 `json:"minPartSize"`
}
type uploadSTSSignatureInfo struct {
AccelerateCname string `json:"accelerateCname"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
AccessToken string `json:"accessToken"`
AccessTokenExpiration int64 `json:"accessTokenExpiration"`
Bucket string `json:"bucket"`
Cname string `json:"cname"`
EndPoint string `json:"endPoint"`
ObjectKey string `json:"objectKey"`
}
-263
View File
@@ -1,263 +0,0 @@
package alidoc
import (
"context"
"fmt"
"io"
"math"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
netutil "github.com/OpenListTeam/OpenList/v4/internal/net"
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/avast/retry-go"
"github.com/google/uuid"
)
const (
defaultAliDocMultipartThreshold = 16 * 1024 * 1024
defaultAliDocPartSize = 100 * 1024
maxAliDocMultipartParts = 10000
)
func (d *AliDoc) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) error {
size := file.GetSize()
useMultipart := size > defaultAliDocMultipartThreshold
info, err := d.getUploadInfo(ctx, dstDir.GetID(), file.GetName(), size, useMultipart)
if err != nil {
return err
}
if size > 0 {
partSize := calcAliDocPartSize(size, info.Data.FileUploadProtocolConfig.MinPartSize)
if size > partSize && !useMultipart {
useMultipart = true
info, err = d.getUploadInfo(ctx, dstDir.GetID(), file.GetName(), size, true)
if err != nil {
return err
}
}
}
if useMultipart {
err = d.multipartUpload(ctx, file, size, info, up)
} else {
err = d.singleUpload(ctx, file, size, info, up)
}
if err != nil {
return err
}
if err := d.commitUpload(ctx, dstDir.GetID(), file.GetName(), size, info.Data.UploadKey); err != nil {
return err
}
return nil
}
func (d *AliDoc) getUploadInfo(ctx context.Context, parentDentryUUID, name string, fileSize int64, multipart bool) (uploadInfoResp, error) {
var result uploadInfoResp
body := map[string]interface{}{
"uploadType": "STS_SIGNATURE",
"supportUploadTypes": []string{"STS_SIGNATURE", "HTTP_TO_CENTER"},
"parentDentryUuid": parentDentryUUID,
"fileSize": fileSize,
"name": name,
"multipart": multipart,
}
resp, err := d.request(ctx).
SetBody(body).
SetResult(&result).
SetError(&result).
Post(apiBase + "/box/api/v2/file/uploadinfo")
if err != nil {
return result, err
}
if err := checkResp(resp, result.apiResp); err != nil {
return result, err
}
if strings.TrimSpace(result.Data.STSSignatureInfo.Bucket) == "" {
return result, fmt.Errorf("empty upload bucket")
}
return result, nil
}
func (d *AliDoc) commitUpload(ctx context.Context, parentDentryUUID, name string, fileSize int64, uploadKey string) error {
uploadKey = strings.TrimSpace(uploadKey)
if uploadKey == "" {
return fmt.Errorf("empty upload key")
}
var result apiResp
body := map[string]interface{}{
"parentDentryUuid": parentDentryUUID,
"uploadKey": uploadKey,
"fileSize": fileSize,
"name": name,
"toPrevDentryUuid": nil,
"toNextDentryUuid": nil,
"batchId": uuid.NewString(),
"batchUploadType": 1,
"batchParentDentryUuid": parentDentryUUID,
}
resp, err := d.request(ctx).
SetBody(body).
SetResult(&result).
SetError(&result).
Post(apiBase + "/box/api/v2/file/commit")
if err != nil {
return err
}
return checkResp(resp, result)
}
func calcAliDocPartSize(fileSize, minPartSize int64) int64 {
partSize := minPartSize
if partSize <= 0 {
partSize = defaultAliDocPartSize
}
if fileSize <= 0 {
return partSize
}
minRequired := int64(math.Ceil(float64(fileSize) / maxAliDocMultipartParts))
if minRequired > partSize {
partSize = minRequired
}
return partSize
}
func (d *AliDoc) singleUpload(ctx context.Context, src model.FileStreamer, size int64, info uploadInfoResp, up driver.UpdateProgress) error {
bucket, objectKey, err := d.newOSSBucket(info)
if err != nil {
return err
}
err = bucket.PutObject(
objectKey,
driver.NewLimitedUploadStream(ctx, io.TeeReader(src, driver.NewProgress(size, up))),
)
if err != nil {
return err
}
up(100)
return nil
}
func (d *AliDoc) multipartUpload(ctx context.Context, src model.FileStreamer, size int64, info uploadInfoResp, up driver.UpdateProgress) error {
bucket, objectKey, err := d.newOSSBucket(info)
if err != nil {
return err
}
imur, err := bucket.InitiateMultipartUpload(objectKey, oss.Sequential())
if err != nil {
return err
}
partSize := calcAliDocPartSize(size, info.Data.FileUploadProtocolConfig.MinPartSize)
partNum := int((size + partSize - 1) / partSize)
parts := make([]oss.UploadPart, 0, partNum)
ss, err := streamPkg.NewStreamSectionReader(src, int(partSize), &up)
if err != nil {
return err
}
var offset int64
for partNumber := 1; partNumber <= partNum; partNumber++ {
if err := ctx.Err(); err != nil {
return err
}
length := partSize
if remain := size - offset; remain < length {
length = remain
}
reader, err := ss.GetSectionReader(offset, length)
if err != nil {
return err
}
var part oss.UploadPart
var uploadErr error
err = retry.Do(func() error {
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return err
}
part, uploadErr = bucket.UploadPart(
imur,
driver.NewLimitedUploadStream(ctx, reader),
length,
partNumber,
)
return uploadErr
},
retry.Context(ctx),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Second),
)
ss.FreeSectionReader(reader)
if err != nil {
return err
}
parts = append(parts, part)
up(100 * float64(len(parts)) / float64(partNum+1))
offset += length
}
_, err = bucket.CompleteMultipartUpload(imur, parts)
if err != nil {
return err
}
up(100)
return nil
}
func (d *AliDoc) newOSSBucket(info uploadInfoResp) (*oss.Bucket, string, error) {
sts := info.Data.STSSignatureInfo
objectKey := strings.TrimSpace(sts.ObjectKey)
if objectKey == "" {
objectKey = strings.TrimSpace(info.Data.UploadKey)
}
if objectKey == "" {
return nil, "", fmt.Errorf("empty upload object key")
}
endpoint, useCname := pickAliDocOSSEndpoint(sts)
if endpoint == "" {
return nil, "", fmt.Errorf("empty upload endpoint")
}
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
options := []oss.ClientOption{oss.SecurityToken(sts.AccessToken)}
if useCname {
options = append(options, oss.UseCname(true))
}
client, err := netutil.NewOSSClient(
endpoint,
sts.AccessKeyID,
sts.AccessKeySecret,
options...,
)
if err != nil {
return nil, "", err
}
bucket, err := client.Bucket(sts.Bucket)
if err != nil {
return nil, "", err
}
return bucket, objectKey, nil
}
func pickAliDocOSSEndpoint(sts uploadSTSSignatureInfo) (endpoint string, useCname bool) {
if endpoint = strings.TrimSpace(sts.EndPoint); endpoint != "" {
return endpoint, false
}
if endpoint = strings.TrimSpace(sts.Cname); endpoint != "" {
return endpoint, true
}
if endpoint = strings.TrimSpace(sts.AccelerateCname); endpoint != "" {
return endpoint, true
}
return "", false
}
-135
View File
@@ -1,135 +0,0 @@
package alidoc
import (
"context"
"fmt"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/go-resty/resty/v2"
)
const apiBase = "https://alidocs.dingtalk.com"
func (d *AliDoc) request(ctx context.Context) *resty.Request {
return d.client.R().
SetContext(ctx).
SetHeader("Cookie", d.Cookie).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Referer", apiBase+"/").
SetHeader("Origin", apiBase)
}
func msToTime(v int64) time.Time {
if v <= 0 {
return time.Time{}
}
return time.UnixMilli(v)
}
func checkResp(resp *resty.Response, result apiResp) error {
if resp != nil && resp.IsError() {
if msg := result.ErrMessage(); msg != "" {
return fmt.Errorf("%s", msg)
}
return fmt.Errorf("http error: %d", resp.StatusCode())
}
if !result.IsSuccess || result.Status != 200 {
msg := result.ErrMessage()
if msg == "" {
msg = "request failed"
}
return fmt.Errorf("%s", msg)
}
return nil
}
func toObj(item dentry) model.Obj {
return &model.Object{
ID: item.DentryUUID,
Name: item.Name,
Size: item.FileSize,
Modified: msToTime(item.UpdatedTime),
Ctime: msToTime(item.CreatedTime),
IsFolder: item.DentryType == "folder",
}
}
func firstDownloadURL(resp downloadResp) (string, error) {
if len(resp.Data.OSSURLPreSignatureInfo.PreSignURLs) == 0 {
return "", fmt.Errorf("empty download url")
}
return resp.Data.OSSURLPreSignatureInfo.PreSignURLs[0], nil
}
func newClient() *resty.Client {
client := base.NewRestyClient()
client.SetHeader("User-Agent", base.UserAgent)
return client
}
func (d *AliDoc) post(ctx context.Context, path string, body interface{}) error {
var result apiResp
resp, err := d.request(ctx).
SetBody(body).
SetResult(&result).
SetError(&result).
Post(apiBase + path)
if err != nil {
return err
}
return checkResp(resp, result)
}
func (d *AliDoc) checkCookie(ctx context.Context) error {
var result apiResp
resp, err := d.request(ctx).
SetResult(&result).
SetError(&result).
Get(apiBase + "/portal/api/v1/mine/info")
if err != nil {
return err
}
return checkResp(resp, result)
}
func (d *AliDoc) list(ctx context.Context, dentryUUID string) ([]dentry, error) {
var result listResp
resp, err := d.request(ctx).
SetQueryParam("dentryUuid", dentryUUID).
SetQueryParam("withParentAncestors", "true").
SetQueryParam("orderType", "SORT_KEY").
SetQueryParam("sortType", "desc").
SetQueryParam("listDentrySource", "2").
SetQueryParam("pageSize", "1000").
SetResult(&result).
SetError(&result).
Get(apiBase + "/box/api/v2/dentry/list")
if err != nil {
return nil, err
}
if err := checkResp(resp, result.apiResp); err != nil {
return nil, err
}
return result.Data.Children, nil
}
func (d *AliDoc) download(ctx context.Context, dentryUUID string) (downloadResp, error) {
var result downloadResp
resp, err := d.request(ctx).
SetQueryParam("dentryUuid", dentryUUID).
SetQueryParam("version", "1").
SetQueryParam("supportDownloadTypes", "URL_PRE_SIGNATURE,HTTP_TO_CENTER").
SetQueryParam("downloadType", "URL_PRE_SIGNATURE").
SetResult(&result).
SetError(&result).
Get(apiBase + "/box/api/v2/file/download")
if err != nil {
return result, err
}
if err := checkResp(resp, result.apiResp); err != nil {
return result, err
}
return result, nil
}
-379
View File
@@ -1,379 +0,0 @@
package alist_v3
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
)
type AListV3 struct {
model.Storage
Addition
}
func (d *AListV3) Config() driver.Config {
return config
}
func (d *AListV3) GetAddition() driver.Additional {
return &d.Addition
}
func (d *AListV3) Init(ctx context.Context) error {
d.Addition.Address = strings.TrimSuffix(d.Addition.Address, "/")
var resp common.Resp[MeResp]
_, _, err := d.request("/me", http.MethodGet, func(req *resty.Request) {
req.SetResult(&resp)
})
if err != nil {
return err
}
// if the username is not empty and the username is not the same as the current username, then login again
if d.Username != resp.Data.Username {
err = d.login()
if err != nil {
return err
}
}
// re-get the user info
_, _, err = d.request("/me", http.MethodGet, func(req *resty.Request) {
req.SetResult(&resp)
})
if err != nil {
return err
}
if utils.SliceContains(resp.Data.Role, model.GUEST) {
u := d.Address + "/api/public/settings"
res, err := base.RestyClient.R().Get(u)
if err != nil {
return err
}
allowMounted := utils.Json.Get(res.Body(), "data", conf.AllowMounted).ToString() == "true"
if !allowMounted {
return fmt.Errorf("the site does not allow mounted")
}
}
return err
}
func (d *AListV3) Drop(ctx context.Context) error {
return nil
}
func (d *AListV3) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
var resp common.Resp[FsListResp]
_, _, err := d.request("/fs/list", http.MethodPost, func(req *resty.Request) {
req.SetResult(&resp).SetBody(ListReq{
PageReq: model.PageReq{
Page: 1,
PerPage: 0,
},
Path: dir.GetPath(),
Password: d.MetaPassword,
Refresh: false,
})
})
if err != nil {
return nil, err
}
var files []model.Obj
for _, f := range resp.Data.Content {
file := model.ObjThumb{
Object: model.Object{
Name: f.Name,
Modified: f.Modified,
Ctime: f.Created,
Size: f.Size,
IsFolder: f.IsDir,
HashInfo: utils.FromString(f.HashInfo),
},
Thumbnail: model.Thumbnail{Thumbnail: f.Thumb},
}
files = append(files, &file)
}
return files, nil
}
func (d *AListV3) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
var resp common.Resp[FsGetResp]
headers := map[string]string{
"User-Agent": base.UserAgent,
}
// if PassUAToUpsteam is true, then pass the user-agent to the upstream
if d.PassUAToUpsteam {
userAgent := args.Header.Get("user-agent")
if userAgent != "" {
headers["User-Agent"] = userAgent
}
}
// if PassIPToUpsteam is true, then pass the ip address to the upstream
if d.PassIPToUpsteam {
ip := args.IP
if ip != "" {
headers["X-Forwarded-For"] = ip
headers["X-Real-Ip"] = ip
}
}
_, _, err := d.request("/fs/get", http.MethodPost, func(req *resty.Request) {
req.SetResult(&resp).SetBody(FsGetReq{
Path: file.GetPath(),
Password: d.MetaPassword,
}).SetHeaders(headers)
})
if err != nil {
return nil, err
}
return &model.Link{
URL: resp.Data.RawURL,
}, nil
}
func (d *AListV3) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
_, _, err := d.request("/fs/mkdir", http.MethodPost, func(req *resty.Request) {
req.SetBody(MkdirOrLinkReq{
Path: path.Join(parentDir.GetPath(), dirName),
})
})
return err
}
func (d *AListV3) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
_, _, err := d.request("/fs/move", http.MethodPost, func(req *resty.Request) {
req.SetBody(MoveCopyReq{
SrcDir: path.Dir(srcObj.GetPath()),
DstDir: dstDir.GetPath(),
Names: []string{srcObj.GetName()},
})
})
return err
}
func (d *AListV3) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
_, _, err := d.request("/fs/rename", http.MethodPost, func(req *resty.Request) {
req.SetBody(RenameReq{
Path: srcObj.GetPath(),
Name: newName,
})
})
return err
}
func (d *AListV3) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
_, _, err := d.request("/fs/copy", http.MethodPost, func(req *resty.Request) {
req.SetBody(MoveCopyReq{
SrcDir: path.Dir(srcObj.GetPath()),
DstDir: dstDir.GetPath(),
Names: []string{srcObj.GetName()},
})
})
return err
}
func (d *AListV3) Remove(ctx context.Context, obj model.Obj) error {
_, _, err := d.request("/fs/remove", http.MethodPost, func(req *resty.Request) {
req.SetBody(RemoveReq{
Dir: path.Dir(obj.GetPath()),
Names: []string{obj.GetName()},
})
})
return err
}
func (d *AListV3) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) error {
reader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
Reader: s,
UpdateProgress: up,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPut, d.Address+"/api/fs/put", reader)
if err != nil {
return err
}
req.Header.Set("Authorization", d.Token)
req.Header.Set("File-Path", path.Join(dstDir.GetPath(), s.GetName()))
req.Header.Set("Password", d.MetaPassword)
if md5 := s.GetHash().GetHash(utils.MD5); len(md5) > 0 {
req.Header.Set("X-File-Md5", md5)
}
if sha1 := s.GetHash().GetHash(utils.SHA1); len(sha1) > 0 {
req.Header.Set("X-File-Sha1", sha1)
}
if sha256 := s.GetHash().GetHash(utils.SHA256); len(sha256) > 0 {
req.Header.Set("X-File-Sha256", sha256)
}
req.ContentLength = s.GetSize()
// client := base.NewHttpClient()
// client.Timeout = time.Hour * 6
res, err := base.HttpClient.Do(req)
if err != nil {
return err
}
bytes, err := io.ReadAll(res.Body)
if err != nil {
return err
}
log.Debugf("[openlist] response body: %s", string(bytes))
if res.StatusCode >= 400 {
return fmt.Errorf("request failed, status: %s", res.Status)
}
code := utils.Json.Get(bytes, "code").ToInt()
if code != 200 {
if code == 401 || code == 403 {
err = d.login()
if err != nil {
return err
}
}
return fmt.Errorf("request failed,code: %d, message: %s", code, utils.Json.Get(bytes, "message").ToString())
}
return nil
}
func (d *AListV3) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) {
if !d.ForwardArchiveReq {
return nil, errs.NotImplement
}
var resp common.Resp[ArchiveMetaResp]
_, code, err := d.request("/fs/archive/meta", http.MethodPost, func(req *resty.Request) {
req.SetResult(&resp).SetBody(ArchiveMetaReq{
ArchivePass: args.Password,
Password: d.MetaPassword,
Path: obj.GetPath(),
Refresh: false,
})
})
if code == 202 {
return nil, errs.WrongArchivePassword
}
if err != nil {
return nil, err
}
var tree []model.ObjTree
if resp.Data.Content != nil {
tree = make([]model.ObjTree, 0, len(resp.Data.Content))
for _, content := range resp.Data.Content {
tree = append(tree, &content)
}
}
return &model.ArchiveMetaInfo{
Comment: resp.Data.Comment,
Encrypted: resp.Data.Encrypted,
Tree: tree,
}, nil
}
func (d *AListV3) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) {
if !d.ForwardArchiveReq {
return nil, errs.NotImplement
}
var resp common.Resp[ArchiveListResp]
_, code, err := d.request("/fs/archive/list", http.MethodPost, func(req *resty.Request) {
req.SetResult(&resp).SetBody(ArchiveListReq{
ArchiveMetaReq: ArchiveMetaReq{
ArchivePass: args.Password,
Password: d.MetaPassword,
Path: obj.GetPath(),
Refresh: false,
},
PageReq: model.PageReq{
Page: 1,
PerPage: 0,
},
InnerPath: args.InnerPath,
})
})
if code == 202 {
return nil, errs.WrongArchivePassword
}
if err != nil {
return nil, err
}
var files []model.Obj
for _, f := range resp.Data.Content {
file := model.ObjThumb{
Object: model.Object{
Name: f.Name,
Modified: f.Modified,
Ctime: f.Created,
Size: f.Size,
IsFolder: f.IsDir,
HashInfo: utils.FromString(f.HashInfo),
},
Thumbnail: model.Thumbnail{Thumbnail: f.Thumb},
}
files = append(files, &file)
}
return files, nil
}
func (d *AListV3) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) {
if !d.ForwardArchiveReq {
return nil, errs.NotSupport
}
var resp common.Resp[ArchiveMetaResp]
_, _, err := d.request("/fs/archive/meta", http.MethodPost, func(req *resty.Request) {
req.SetResult(&resp).SetBody(ArchiveMetaReq{
ArchivePass: args.Password,
Password: d.MetaPassword,
Path: obj.GetPath(),
Refresh: false,
})
})
if err != nil {
return nil, err
}
return &model.Link{
URL: fmt.Sprintf("%s?inner=%s&pass=%s&sign=%s",
resp.Data.RawURL,
utils.EncodePath(args.InnerPath, true),
url.QueryEscape(args.Password),
resp.Data.Sign),
}, nil
}
func (d *AListV3) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) error {
if !d.ForwardArchiveReq {
return errs.NotImplement
}
dir, name := path.Split(srcObj.GetPath())
_, _, err := d.request("/fs/archive/decompress", http.MethodPost, func(req *resty.Request) {
req.SetBody(DecompressReq{
ArchivePass: args.Password,
CacheFull: args.CacheFull,
DstDir: dstDir.GetPath(),
InnerPath: args.InnerPath,
Name: []string{name},
PutIntoNewDir: args.PutIntoNewDir,
SrcDir: dir,
})
})
return err
}
func (d *AListV3) ResolveLinkCacheMode(_ string) driver.LinkCacheMode {
var mode driver.LinkCacheMode
if d.PassIPToUpsteam {
mode |= driver.LinkCacheIP
}
if d.PassUAToUpsteam {
mode |= driver.LinkCacheUA
}
return mode
}
var _ driver.Driver = (*AListV3)(nil)
-32
View File
@@ -1,32 +0,0 @@
package alist_v3
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
type Addition struct {
driver.RootPath
Address string `json:"url" required:"true"`
MetaPassword string `json:"meta_password"`
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
PassIPToUpsteam bool `json:"pass_ip_to_upsteam" default:"true"`
PassUAToUpsteam bool `json:"pass_ua_to_upsteam" default:"true"`
ForwardArchiveReq bool `json:"forward_archive_requests" default:"true"`
}
var config = driver.Config{
Name: "AList V3",
LocalSort: true,
DefaultRoot: "/",
ProxyRangeOption: true,
LinkCacheMode: driver.LinkCacheAuto,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &AListV3{}
})
}
-182
View File
@@ -1,182 +0,0 @@
package alist_v3
import (
"encoding/json"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
type ListReq struct {
model.PageReq
Path string `json:"path" form:"path"`
Password string `json:"password" form:"password"`
Refresh bool `json:"refresh"`
}
type ObjResp struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
Modified time.Time `json:"modified"`
Created time.Time `json:"created"`
Sign string `json:"sign"`
Thumb string `json:"thumb"`
Type int `json:"type"`
HashInfo string `json:"hashinfo"`
}
type FsListResp struct {
Content []ObjResp `json:"content"`
Total int64 `json:"total"`
Readme string `json:"readme"`
Write bool `json:"write"`
Provider string `json:"provider"`
}
type FsGetReq struct {
Path string `json:"path" form:"path"`
Password string `json:"password" form:"password"`
}
type FsGetResp struct {
ObjResp
RawURL string `json:"raw_url"`
Readme string `json:"readme"`
Provider string `json:"provider"`
Related []ObjResp `json:"related"`
}
type MkdirOrLinkReq struct {
Path string `json:"path" form:"path"`
}
type MoveCopyReq struct {
SrcDir string `json:"src_dir"`
DstDir string `json:"dst_dir"`
Names []string `json:"names"`
}
type RenameReq struct {
Path string `json:"path"`
Name string `json:"name"`
}
type RemoveReq struct {
Dir string `json:"dir"`
Names []string `json:"names"`
}
type LoginResp struct {
Token string `json:"token"`
}
type MeResp struct {
Id int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
BasePath string `json:"base_path"`
Role IntSlice `json:"role"`
Disabled bool `json:"disabled"`
Permission int `json:"permission"`
SsoId string `json:"sso_id"`
Otp bool `json:"otp"`
}
type IntSlice []int
func (s *IntSlice) UnmarshalJSON(b []byte) error {
var i int
if json.Unmarshal(b, &i) == nil {
*s = []int{i}
return nil
}
return json.Unmarshal(b, (*[]int)(s))
}
type ArchiveMetaReq struct {
ArchivePass string `json:"archive_pass"`
Password string `json:"password"`
Path string `json:"path"`
Refresh bool `json:"refresh"`
}
type TreeResp struct {
ObjResp
Children []TreeResp `json:"children"`
hashCache *utils.HashInfo
}
func (t *TreeResp) GetSize() int64 {
return t.Size
}
func (t *TreeResp) GetName() string {
return t.Name
}
func (t *TreeResp) ModTime() time.Time {
return t.Modified
}
func (t *TreeResp) CreateTime() time.Time {
return t.Created
}
func (t *TreeResp) IsDir() bool {
return t.ObjResp.IsDir
}
func (t *TreeResp) GetHash() utils.HashInfo {
return utils.FromString(t.HashInfo)
}
func (t *TreeResp) GetID() string {
return ""
}
func (t *TreeResp) GetPath() string {
return ""
}
func (t *TreeResp) GetChildren() []model.ObjTree {
ret := make([]model.ObjTree, 0, len(t.Children))
for _, child := range t.Children {
ret = append(ret, &child)
}
return ret
}
func (t *TreeResp) Thumb() string {
return t.ObjResp.Thumb
}
type ArchiveMetaResp struct {
Comment string `json:"comment"`
Encrypted bool `json:"encrypted"`
Content []TreeResp `json:"content"`
RawURL string `json:"raw_url"`
Sign string `json:"sign"`
}
type ArchiveListReq struct {
model.PageReq
ArchiveMetaReq
InnerPath string `json:"inner_path"`
}
type ArchiveListResp struct {
Content []ObjResp `json:"content"`
Total int64 `json:"total"`
}
type DecompressReq struct {
ArchivePass string `json:"archive_pass"`
CacheFull bool `json:"cache_full"`
DstDir string `json:"dst_dir"`
InnerPath string `json:"inner_path"`
Name []string `json:"name"`
PutIntoNewDir bool `json:"put_into_new_dir"`
SrcDir string `json:"src_dir"`
}
-65
View File
@@ -1,65 +0,0 @@
package alist_v3
import (
"fmt"
"net/http"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
)
func (d *AListV3) login() error {
if d.Username == "" {
return nil
}
var resp common.Resp[LoginResp]
_, _, err := d.request("/auth/login", http.MethodPost, func(req *resty.Request) {
req.SetResult(&resp).SetBody(base.Json{
"username": d.Username,
"password": d.Password,
})
})
if err != nil {
return err
}
d.Token = resp.Data.Token
op.MustSaveDriverStorage(d)
return nil
}
func (d *AListV3) request(api, method string, callback base.ReqCallback, retry ...bool) ([]byte, int, error) {
url := d.Address + "/api" + api
req := base.RestyClient.R()
req.SetHeader("Authorization", d.Token)
if callback != nil {
callback(req)
}
res, err := req.Execute(method, url)
if err != nil {
code := 0
if res != nil {
code = res.StatusCode()
}
return nil, code, err
}
log.Debugf("[openlist] response body: %s", res.String())
if res.StatusCode() >= 400 {
return nil, res.StatusCode(), fmt.Errorf("request failed, status: %s", res.Status())
}
code := utils.Json.Get(res.Body(), "code").ToInt()
if code != 200 {
if (code == 401 || code == 403) && !utils.IsBool(retry...) {
err = d.login()
if err != nil {
return nil, code, err
}
return d.request(api, method, callback, true)
}
return nil, code, fmt.Errorf("request failed,code: %d, message: %s", code, utils.Json.Get(res.Body(), "message").ToString())
}
return res.Body(), 200, nil
}
+3 -6
View File
@@ -334,13 +334,10 @@ func (d *AliDrive) GetDetails(ctx context.Context) (*model.StorageDetails, error
if err != nil {
return nil, err
}
used := utils.Json.Get(res, "drive_used_size").ToInt64()
total := utils.Json.Get(res, "drive_total_size").ToInt64()
used := utils.Json.Get(res, "drive_used_size").ToUint64()
total := utils.Json.Get(res, "drive_total_size").ToUint64()
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
},
DiskUsage: driver.DiskUsageFromUsedAndTotal(used, total),
}, nil
}
+4 -6
View File
@@ -77,6 +77,7 @@ func (d *AliyundriveOpen) GetRoot(ctx context.Context) (model.Obj, error) {
ID: d.RootFolderID,
Path: "/",
Name: "root",
Size: 0,
Modified: d.Modified,
IsFolder: true,
}, nil
@@ -295,13 +296,10 @@ func (d *AliyundriveOpen) GetDetails(ctx context.Context) (*model.StorageDetails
if err != nil {
return nil, err
}
total := utils.Json.Get(res, "personal_space_info", "total_size").ToInt64()
used := utils.Json.Get(res, "personal_space_info", "used_size").ToInt64()
total := utils.Json.Get(res, "personal_space_info", "total_size").ToUint64()
used := utils.Json.Get(res, "personal_space_info", "used_size").ToUint64()
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
},
DiskUsage: driver.DiskUsageFromUsedAndTotal(used, total),
}, nil
}
+2 -2
View File
@@ -242,11 +242,11 @@ func (d *AliyundriveOpen) upload(ctx context.Context, dstDir model.Obj, stream m
if err != nil {
return nil, err
}
rateLimitedRd := driver.NewLimitedUploadStream(ctx, rd)
err = retry.Do(func() error {
rd.Seek(0, io.SeekStart)
return d.uploadPart(ctx, driver.NewLimitedUploadStream(ctx, rd), createResp.PartInfoList[i])
return d.uploadPart(ctx, rateLimitedRd, createResp.PartInfoList[i])
},
retry.Context(ctx),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Second))
+1
View File
@@ -38,6 +38,7 @@ func (d *AliyundriveOpen) _refreshToken(ctx context.Context) (string, string, er
return "", "", err
}
_, err = base.RestyClient.R().
SetHeader("User-Agent", "Mozilla/5.0 (Macintosh; Apple macOS 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/138.0.0.0 Openlist/425.6.30").
SetResult(&resp).
SetQueryParams(map[string]string{
"refresh_ui": d.RefreshToken,
+1 -7
View File
@@ -13,28 +13,22 @@ import (
_ "github.com/OpenListTeam/OpenList/v4/drivers/189_tv"
_ "github.com/OpenListTeam/OpenList/v4/drivers/189pc"
_ "github.com/OpenListTeam/OpenList/v4/drivers/alias"
_ "github.com/OpenListTeam/OpenList/v4/drivers/alidoc"
_ "github.com/OpenListTeam/OpenList/v4/drivers/alist_v3"
_ "github.com/OpenListTeam/OpenList/v4/drivers/aliyundrive"
_ "github.com/OpenListTeam/OpenList/v4/drivers/aliyundrive_open"
_ "github.com/OpenListTeam/OpenList/v4/drivers/aliyundrive_share"
_ "github.com/OpenListTeam/OpenList/v4/drivers/autoindex"
_ "github.com/OpenListTeam/OpenList/v4/drivers/azure_blob"
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_netdisk"
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo"
_ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing"
_ "github.com/OpenListTeam/OpenList/v4/drivers/chunk"
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed"
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve"
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve_v4"
_ "github.com/OpenListTeam/OpenList/v4/drivers/cnb_releases"
_ "github.com/OpenListTeam/OpenList/v4/drivers/crypt"
_ "github.com/OpenListTeam/OpenList/v4/drivers/degoo"
_ "github.com/OpenListTeam/OpenList/v4/drivers/doubao"
_ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_new"
_ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_share"
_ "github.com/OpenListTeam/OpenList/v4/drivers/dropbox"
_ "github.com/OpenListTeam/OpenList/v4/drivers/emby"
_ "github.com/OpenListTeam/OpenList/v4/drivers/febbox"
_ "github.com/OpenListTeam/OpenList/v4/drivers/ftp"
_ "github.com/OpenListTeam/OpenList/v4/drivers/github"
@@ -83,11 +77,11 @@ import (
_ "github.com/OpenListTeam/OpenList/v4/drivers/webdav"
_ "github.com/OpenListTeam/OpenList/v4/drivers/weiyun"
_ "github.com/OpenListTeam/OpenList/v4/drivers/wopan"
_ "github.com/OpenListTeam/OpenList/v4/drivers/wps"
_ "github.com/OpenListTeam/OpenList/v4/drivers/yandex_disk"
)
// All do nothing,just for import
// same as _ import
func All() {
}
-169
View File
@@ -1,169 +0,0 @@
package autoindex
import (
"context"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/antchfx/htmlquery"
"github.com/antchfx/xpath"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
type AutoIndex struct {
model.Storage
Addition
itemXPath *xpath.Expr
nameXPath *xpath.Expr
modifiedXPath *xpath.Expr
sizeXPath *xpath.Expr
ignores map[string]any
}
func (d *AutoIndex) Config() driver.Config {
return config
}
func (d *AutoIndex) GetAddition() driver.Additional {
return &d.Addition
}
func (d *AutoIndex) Init(ctx context.Context) error {
var err error
d.itemXPath, err = xpath.Compile(d.ItemXPath)
if err != nil {
return errors.WithMessage(err, "failed to compile Item XPath")
}
d.nameXPath, err = xpath.Compile(d.NameXPath)
if err != nil {
return errors.WithMessage(err, "failed to compile Name XPath")
}
if len(d.ModifiedXPath) > 0 {
d.modifiedXPath, err = xpath.Compile(d.ModifiedXPath)
if err != nil {
return errors.WithMessage(err, "failed to compile Modified XPath")
}
}
if len(d.SizeXPath) > 0 {
d.sizeXPath, err = xpath.Compile(d.SizeXPath)
if err != nil {
return errors.WithMessage(err, "failed to compile Size XPath")
}
}
ignores := strings.Split(d.IgnoreFileNames, "\n")
d.ignores = make(map[string]any, len(ignores))
for _, i := range ignores {
i = strings.TrimSpace(i)
if len(i) == 0 {
continue
}
d.ignores[i] = struct{}{}
}
hasScheme := strings.Contains(d.URL, "://")
hasSuffix := strings.HasSuffix(d.URL, "/")
if !hasScheme || !hasSuffix {
if !hasSuffix {
d.URL = d.URL + "/"
}
if !hasScheme {
d.URL = "https://" + d.URL
}
op.MustSaveDriverStorage(d)
}
return nil
}
func (d *AutoIndex) Drop(ctx context.Context) error {
return nil
}
func (d *AutoIndex) GetRoot(ctx context.Context) (model.Obj, error) {
return &model.Object{
Name: op.RootName,
Path: d.URL,
Modified: d.Modified,
Mask: model.Locked,
IsFolder: true,
}, nil
}
func (d *AutoIndex) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
res, err := base.RestyClient.R().
SetContext(ctx).
SetDoNotParseResponse(true).
Get(dir.GetPath())
if err != nil {
return nil, errors.WithMessagef(err, "failed to get url [%s]", dir.GetPath())
}
defer res.RawResponse.Body.Close()
doc, err := htmlquery.Parse(res.RawBody())
if err != nil {
return nil, errors.WithMessagef(err, "failed to parse [%s]", dir.GetPath())
}
itemsIter := d.itemXPath.Select(htmlquery.CreateXPathNavigator(doc))
var objs []model.Obj
for itemsIter.MoveNext() {
nameFull, err := parseString(d.nameXPath.Evaluate(itemsIter.Current().Copy()))
if err != nil {
log.Warnf("skip invalid name evaluating result: %v", err)
continue
}
nameFull = strings.TrimSpace(nameFull)
name, isDir := strings.CutSuffix(nameFull, "/")
if _, ok := d.ignores[name]; ok {
continue
}
var size int64 = 0
exact := false
modified := time.Now()
if d.sizeXPath != nil {
size, exact, err = parseSize(d.sizeXPath.Evaluate(itemsIter.Current().Copy()))
if err != nil {
log.Errorf("failed to parse size of %s: %v", name, err)
}
}
if d.modifiedXPath != nil {
modified, err = parseTime(d.modifiedXPath.Evaluate(itemsIter.Current().Copy()), d.ModifiedTimeFormat)
if err != nil {
log.Errorf("failed to parse modified time of %s: %v", name, err)
}
}
var o model.Obj = &model.Object{
Name: name,
IsFolder: isDir,
Path: dir.GetPath() + nameFull,
Modified: modified,
Size: size,
}
if exact {
o = &exactSizeObj{Obj: o}
}
objs = append(objs, o)
}
return objs, nil
}
func (d *AutoIndex) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if _, ok := file.(*exactSizeObj); ok || args.Redirect {
return &model.Link{URL: file.GetPath()}, nil
}
res, err := base.RestyClient.R().
SetContext(ctx).
SetDoNotParseResponse(true).
Head(file.GetPath())
if err != nil {
return nil, errors.WithMessagef(err, "failed to head [%s]", file.GetPath())
}
_ = res.RawResponse.Body.Close()
return &model.Link{
URL: file.GetPath(),
ContentLength: res.RawResponse.ContentLength,
}, nil
}
var _ driver.Driver = (*AutoIndex)(nil)
-29
View File
@@ -1,29 +0,0 @@
package autoindex
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
type Addition struct {
URL string `json:"url" required:"true"`
ItemXPath string `json:"item_xpath" required:"true"`
NameXPath string `json:"name_xpath" required:"true"`
ModifiedXPath string `json:"modified_xpath"`
SizeXPath string `json:"size_xpath"`
IgnoreFileNames string `json:"ignore_file_names" type:"text" default:".\n..\nParent Directory\nUp"`
ModifiedTimeFormat string `json:"modified_time_format" default:"02-Jan-2006 15:04" help:"Must be based on the time point Mon Jan 2 15:04:05 -0700 MST 2006"`
}
var config = driver.Config{
Name: "AutoIndex",
LocalSort: true,
CheckStatus: true,
NoUpload: true,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &AutoIndex{}
})
}
-13
View File
@@ -1,13 +0,0 @@
package autoindex
import (
"fmt"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
var (
errEmptyEvaluateResult = fmt.Errorf("empty result")
)
type exactSizeObj struct{ model.Obj }

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