mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 21:13:10 +08:00
Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 579ceff18f | |||
| 4580c4db33 | |||
| 5447ecb072 | |||
| f18b4acc76 | |||
| d6109a7940 | |||
| 0463da4034 | |||
| 2d51c9ab4b | |||
| d9d8aa24e6 | |||
| 55530ff171 | |||
| 6247cf7be2 | |||
| eee910babb | |||
| 6b55a82ffe | |||
| 93dac1655f | |||
| 6ad44605c0 | |||
| d90d84906e | |||
| c3d3da9286 | |||
| 2bdf16d596 | |||
| f8ebaec4f1 | |||
| fc23f4e781 | |||
| 3ea9984aea | |||
| cc11f354f1 | |||
| 8869874b76 | |||
| 523af855ba | |||
| bba3516693 | |||
| f244adf60e | |||
| ce87b45d2d | |||
| 4031b31837 | |||
| 9b34ca5bf1 | |||
| 666b1a039d | |||
| 8f9a09d359 | |||
| 394bb8f80e | |||
| 819fdbd518 | |||
| d220b8b005 | |||
| d12bd4c71e | |||
| 4c611ef8de | |||
| eea8fee7f1 | |||
| c06656958c | |||
| 0d277b8550 | |||
| e0e4de5e82 | |||
| 39fcaf488b | |||
| 31ae0f5bc9 | |||
| 3943180898 | |||
| b1cc7c6a93 | |||
| f6557f3d62 | |||
| 9dc6cb29d9 | |||
| b86e4cb8dc | |||
| fd7fb04ed1 | |||
| 80795b8ec3 | |||
| c2b3666818 | |||
| edbf42898b | |||
| ab51cd6eae | |||
| 4619aa2bc5 | |||
| 376cada3d4 | |||
| 1a6cabf45a | |||
| 0b1e9d0943 | |||
| 18823a23d5 | |||
| 150a90f2ac | |||
| e80ee006ad | |||
| 89077c35a7 | |||
| cc87e88f03 | |||
| 7e9082f90e | |||
| 6d4ad58678 | |||
| d8ad28f5b7 | |||
| 2e27852952 | |||
| 194f941f15 | |||
| ec1f968de1 | |||
| c97173286c | |||
| cca773f07b | |||
| a4ae2acb65 | |||
| f68f3e9f4a | |||
| 6f80df2827 | |||
| 940b69fe38 | |||
| f2f7ee4107 | |||
| cf931d4760 | |||
| be708292eb | |||
| 78f2f0b25d | |||
| 6b92cbd21f | |||
| 84ecda35aa | |||
| 2deb16b26e | |||
| 8495470e63 | |||
| fe56ca483d | |||
| e83835dd80 | |||
| 791a27e2ac | |||
| 28cea1fb2b | |||
| 0a9e71ae67 | |||
| a21bc8bd0f | |||
| bacd087096 | |||
| e1ded83d5f | |||
| ff3cb8b931 | |||
| 7aea393a6d | |||
| 36168c86a0 | |||
| 24332e371f | |||
| 6b702afc0f | |||
| 78db0f7aff | |||
| e469693114 | |||
| 5961289b83 | |||
| 0527dc41af | |||
| b1d298d192 | |||
| 651b5cea1c | |||
| 5aeaf7a8c8 | |||
| 651da18da4 | |||
| 5a5d8d6e0e | |||
| 061c1e2e1e | |||
| caed356200 | |||
| 74af297471 | |||
| 7c54b0e3e4 | |||
| a632356902 | |||
| 82ea8d0d01 | |||
| eb48671202 | |||
| f3e74fbdf1 | |||
| e670322ee3 | |||
| 8930e1945e | |||
| daaba0d7ea | |||
| a81f54488f | |||
| 59bd343140 | |||
| 757f9d383d | |||
| 7ded6dfe2c | |||
| a283f2b52a | |||
| bb3bc31609 | |||
| 744021d80d | |||
| 94eac2e91e | |||
| a92d59d35c | |||
| fcae009bd3 | |||
| f63a5e15ee | |||
| d85980d3d9 | |||
| c8306256ff | |||
| 5273a77854 | |||
| cc3d8a06a7 | |||
| 925a2422d6 | |||
| 33015eee9f | |||
| 1be74776f6 | |||
| 31810f556c | |||
| 30922b6d52 | |||
| 759adfca21 | |||
| bfbc441fa6 | |||
| 866793f485 | |||
| 40b39000ab | |||
| 6eedff851c | |||
| aea77c5967 | |||
| 009d834e71 | |||
| e9ea991584 | |||
| d4aa8b6a15 | |||
| 9e55a0111b | |||
| f869afc71f | |||
| e960035b65 | |||
| a0f001c168 | |||
| c8e7f35761 | |||
| 6ed7db1ee7 | |||
| 5a12c8d2ba | |||
| b86d10fa2f | |||
| aa534a955b | |||
| d403f75da2 | |||
| 3abdf7990b | |||
| 3b017c2b5c | |||
| a42e367660 | |||
| f45473357e | |||
| dcfefce85d | |||
| d349765617 | |||
| ace7482bc2 | |||
| eadf03a4f8 | |||
| da84a800ab | |||
| b05a51a062 | |||
| ffe3f3cfa3 | |||
| 7e55f11e4a | |||
| e7bccc636a | |||
| b7f9e8ef0a | |||
| d7c332ca13 | |||
| 64d39114ee | |||
| 88d256de73 | |||
| 5a92dc2834 | |||
| 726bbf4bd2 | |||
| bd6cf5bc1e | |||
| 089315118d | |||
| 9737006a8d | |||
| 054db9f5eb | |||
| 9b587ee165 | |||
| 9282e8ef09 | |||
| ac80fe19bf | |||
| cd6643a642 | |||
| ba6f96ca4e | |||
| 9cc5dd969b | |||
| 98c32d3be4 | |||
| 4c77b9c7bf | |||
| 3b1760e959 |
@@ -82,9 +82,17 @@ body:
|
||||
label: 复现链接(可选)
|
||||
description: |
|
||||
请提供能复现此问题的链接。
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容(可选)
|
||||
description: |
|
||||
如果此问题是由AI辅助您发现的,请提供全部聊天记录,包括使用的模型信息。
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
|
||||
@@ -82,9 +82,17 @@ body:
|
||||
label: Reproduction Link (optional)
|
||||
description: |
|
||||
Please provide a link to a repo or page that can reproduce this issue.
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI Generated Content (optional)
|
||||
description: |
|
||||
If this issue was identified with the assistance of AI, please provide the complete chat log, including information about the model used.
|
||||
label: AI Generated Content
|
||||
description: Select exactly one option. Do not delete or modify the disclosure text.
|
||||
options:
|
||||
- label: I used AI tools to generate this content
|
||||
- label: I did not use AI tools to generate this content
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI model used
|
||||
description: If you used AI tools, enter the model name; otherwise leave this blank.
|
||||
placeholder: xxx
|
||||
|
||||
@@ -48,9 +48,17 @@ body:
|
||||
label: 附加信息
|
||||
description: |
|
||||
相关的任何其他上下文或截图,或者你觉得有帮助的信息
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI生成内容(可选)
|
||||
description: |
|
||||
如果此请求是由AI辅助您提交的,请提供全部聊天记录,包括使用的模型信息。
|
||||
label: AI生成内容
|
||||
description: 必须且只能勾选一项,请勿删除或修改声明文字。
|
||||
options:
|
||||
- label: 我使用了AI工具生成此内容
|
||||
- label: 我没有使用AI工具生成此内容
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI模型是
|
||||
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
|
||||
placeholder: xxx
|
||||
|
||||
@@ -48,9 +48,17 @@ body:
|
||||
label: Additional Information
|
||||
description: |
|
||||
Any other context or screenshots related to this feature request, or information you find helpful.
|
||||
- type: textarea
|
||||
- type: checkboxes
|
||||
id: aigenerated
|
||||
attributes:
|
||||
label: AI Generated Content (optional)
|
||||
description: |
|
||||
If this request was submitted with the assistance of an AI, please provide the complete chat log, including information about the model used.
|
||||
label: AI Generated Content
|
||||
description: Select exactly one option. Do not delete or modify the disclosure text.
|
||||
options:
|
||||
- label: I used AI tools to generate this content
|
||||
- label: I did not use AI tools to generate this content
|
||||
- type: input
|
||||
id: ai-model
|
||||
attributes:
|
||||
label: AI model used
|
||||
description: If you used AI tools, enter the model name; otherwise leave this blank.
|
||||
placeholder: xxx
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create or update ref
|
||||
id: create-or-update-ref
|
||||
uses: ovsds/create-or-update-ref-action@v1
|
||||
uses: ovsds/create-or-update-ref-action@1157b2991820188d5a8e6f22656775d1d171f762 # v1
|
||||
with:
|
||||
ref: tags/beta
|
||||
sha: ${{ github.sha }}
|
||||
@@ -40,15 +40,18 @@ jobs:
|
||||
npx changelogithub --output CHANGELOG.md
|
||||
|
||||
- name: Upload assets to beta release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: CHANGELOG.md
|
||||
files: CHANGELOG.md
|
||||
prerelease: true
|
||||
tag_name: beta
|
||||
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
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: beta changelog
|
||||
path: ${{ github.workspace }}/CHANGELOG.md
|
||||
@@ -105,14 +108,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.25.0"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Setup web
|
||||
run: bash build.sh dev web
|
||||
@@ -121,7 +124,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Build
|
||||
uses: OpenListTeam/cgo-actions@v1.2.2
|
||||
uses: OpenListTeam/cgo-actions@6fcace5934c36d70503dba06e2396bb58b766130 # v1.2.5
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
flags: ${{ matrix.flags || '-ldflags=' }}
|
||||
@@ -164,13 +167,13 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# See above
|
||||
- name: Upload assets to beta release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
prerelease: true
|
||||
tag_name: beta
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for file in build/compress/*; do
|
||||
gh release upload beta "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Clean illegal characters from matrix.target
|
||||
id: clean_target_name
|
||||
@@ -182,7 +185,7 @@ jobs:
|
||||
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: beta builds for ${{ env.cleaned_target }}
|
||||
path: ${{ github.workspace }}/build/compress/*
|
||||
|
||||
@@ -25,15 +25,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: benjlevesque/short-sha@v3.0
|
||||
- uses: benjlevesque/short-sha@dbe07338b37c456ce06d23409b35a56a7815eef4 # v4.0
|
||||
id: short-sha
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.25.0"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Setup web
|
||||
run: bash build.sh dev web
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Build
|
||||
uses: OpenListTeam/cgo-actions@v1.2.2
|
||||
uses: OpenListTeam/cgo-actions@6fcace5934c36d70503dba06e2396bb58b766130 # v1.2.5
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
flags: ${{ contains(matrix.target, '-musl') && '-ldflags=-linkmode external -extldflags ''-static -fpic''' || '-ldflags=' }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openlist_${{ steps.short-sha.outputs.sha }}_${{ matrix.target }}
|
||||
path: build/*
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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."
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Issue Auto Reply
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-reply:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check issue for unchecked tasks and reply
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
if (context.payload.issue.title.startsWith('[Announcements]')) return;
|
||||
const titleNotEdited = /(请修改标题|Please modify the title)/i.test(context.payload.issue.title);
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
const aiSection = issueBody.match(/^### (AI生成内容|AI Generated Content)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
|
||||
const aiOptionsPattern = aiSection?.[1] === 'AI生成内容'
|
||||
? /^\s*- \[([ xX])\] 我使用了AI工具生成此内容\r?\n- \[([ xX])\] 我没有使用AI工具生成此内容\s*$/
|
||||
: /^\s*- \[([ xX])\] I used AI tools to generate this content\r?\n- \[([ xX])\] I did not use AI tools to generate this content\s*$/;
|
||||
const aiOptions = (aiSection?.[2] || '').match(aiOptionsPattern);
|
||||
const validAiDisclosure = aiOptions !== null && (aiOptions[1] !== ' ') !== (aiOptions[2] !== ' ');
|
||||
const aiModelSection = issueBody.match(/^### (AI模型是|AI model used)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
|
||||
const aiModel = (aiModelSection?.[2] || '').trim();
|
||||
const missingAiModel = validAiDisclosure && aiOptions[1] !== ' ' && (aiModel === '' || aiModel === '_No response_');
|
||||
const confirmNotRead = /- \[[xX]\] (?:我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
|
||||
const closeIssue = titleNotEdited || confirmNotRead || !validAiDisclosure || missingAiModel;
|
||||
let comment;
|
||||
if (titleNotEdited) {
|
||||
comment = `⚠️ 请修改标题以更好地描述您的问题或需求,并删除示例提示。当前 Issue 将被自动关闭并锁定。如需继续提交,请创建新的 Issue。
|
||||
⚠️ Please modify the title to better describe your issue or request, and remove the example prompt. This issue will be automatically closed and locked. If you wish to proceed, please create a new issue.
|
||||
`;
|
||||
} else if (confirmNotRead || !validAiDisclosure || missingAiModel) {
|
||||
comment = `⚠️ 你的 Issue 不符合提交规则。请先阅读相关规范后再重新提交。当前 Issue 将被自动关闭并锁定。如需继续提交,请确认已了解规则后创建新的 Issue。
|
||||
⚠️ Your issue does not comply with the submission rules. Please read the guidelines before submitting again. This issue will be automatically closed and locked. If you wish to proceed, please confirm that you have reviewed the rules before creating a new issue.
|
||||
`;
|
||||
} else if (/- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody.replace(aiSection[0], ''))) {
|
||||
comment = `感谢您联系OpenList。我们会尽快回复您。
|
||||
Thanks for contacting OpenList. We will reply to you as soon as possible.
|
||||
|
||||
由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。
|
||||
如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!
|
||||
Since your issue contains some unchecked tasks, it may be closed after manual review.
|
||||
If you can confirm and provide information for the unchecked tasks, feel free to resubmit.
|
||||
We will pay attention and handle it in a timely manner.
|
||||
|
||||
感谢您的理解与支持!
|
||||
Thank you for your understanding and support!
|
||||
`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
if (closeIssue) {
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
labels: ['invalid']
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
name: Issue or PR Auto Reply
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-reply:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'issues'
|
||||
steps:
|
||||
- name: Check issue for unchecked tasks and reply
|
||||
uses: actions/github-script@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";
|
||||
comment += "由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。\n";
|
||||
comment += "如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!\n";
|
||||
comment += "Since your issue contains some unchecked tasks, it may be closed after manual review.\n";
|
||||
comment += "If you can confirm and provide information for the unchecked tasks, feel free to resubmit.\n";
|
||||
comment += "We will pay attention and handle it in a timely manner.\n\n";
|
||||
comment += "感谢您的理解与支持!\n";
|
||||
comment += "Thank you for your understanding and support!\n";
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
|
||||
pr-title-check:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Check PR title for required prefix and comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title || "";
|
||||
const ok = /^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title);
|
||||
if (!ok) {
|
||||
let comment = "⚠️ PR 标题需以 `feat(): `, `docs(): `, `fix(): `, `style(): `, `refactor(): `, `chore(): ` 其中之一开头,例如:`feat(component): 新增功能`。\n";
|
||||
comment += "⚠️ The PR title must start with `feat(): `, `docs(): `, `fix(): `, `style(): `, or `refactor(): `, `chore(): `. For example: `feat(component): add new feature`.\n\n";
|
||||
comment += "如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。\n";
|
||||
comment += "If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.\n\n";
|
||||
comment += "如果是破坏性变更,请在类型后添加 `!`,例如 `feat(component)!: 破坏性变更`。\n";
|
||||
comment += "For breaking changes, add `!` after the type, e.g., `feat(component)!: breaking change`.\n\n";
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pr-title-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR title for required prefix and comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title;
|
||||
if (/^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title)) return;
|
||||
const comment = `⚠️ PR 标题需以 \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, \`refactor(): \`, \`chore(): \` 其中之一开头,例如:\`feat(component): 新增功能\`。
|
||||
⚠️ The PR title must start with \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, or \`refactor(): \`, \`chore(): \`. For example: \`feat(component): add new feature\`.
|
||||
|
||||
如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。
|
||||
If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.
|
||||
|
||||
如果是破坏性变更,请在类型后添加 \`!\`,例如 \`feat(component)!: 破坏性变更\`。
|
||||
For breaking changes, add \`!\` after the type, e.g., \`feat(component)!: breaking change\`.
|
||||
|
||||
`;
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Prerelease
|
||||
uses: irongut/EditRelease@v1.2.0
|
||||
uses: irongut/EditRelease@ccf529ad26dddf9996e7dd0f24ca5da4ea507cc2 # v1.2.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
id: ${{ github.event.release.id }}
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.target-platform == ''
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
@@ -44,14 +44,15 @@ jobs:
|
||||
swap-storage: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.25.0'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
fetch-tags: true
|
||||
|
||||
- name: Install dependencies
|
||||
if: matrix.target-platform == ''
|
||||
@@ -69,9 +70,9 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
prerelease: false
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for file in build/compress/*; do
|
||||
gh release upload "${{ github.event.release.tag_name }}" "$file" --clobber
|
||||
done
|
||||
|
||||
@@ -43,15 +43,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.25.0'
|
||||
go-version: '1.27'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
overwrite: true
|
||||
@@ -83,15 +83,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.25.0'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME_LITE }}
|
||||
overwrite: true
|
||||
@@ -146,21 +146,21 @@ jobs:
|
||||
tag_favor: "suffix=-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -168,14 +168,14 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
@@ -230,21 +230,21 @@ jobs:
|
||||
tag_favor: "suffix=-lite-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME_LITE }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -252,14 +252,14 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
|
||||
@@ -274,7 +274,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Sync GitHub to Gitee
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -32,15 +32,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: '1.25.0'
|
||||
go-version: '1.27.1'
|
||||
|
||||
- name: Cache Musl
|
||||
id: cache-musl
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: build/musl-libs
|
||||
key: docker-musl-libs-v2
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
overwrite: true
|
||||
@@ -97,21 +97,21 @@ jobs:
|
||||
tag_favor: "suffix=-aio,onlatest=true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: 'build/'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -119,14 +119,14 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub Container Registry
|
||||
if: env.IMAGE_PUSH == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
|
||||
@@ -16,20 +16,33 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Makefile hash update
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
|
||||
repository: ${{ vars.HOOK_REPO || 'OpenListTeam/OpenList-OpenWRT' }}
|
||||
event-type: update-hashes
|
||||
client-payload: |
|
||||
{
|
||||
"source_repository": "${{ github.repository }}",
|
||||
"release_tag": "${{ inputs.tag || github.ref_name }}",
|
||||
"release_name": "${{ inputs.tag || github.ref_name }}",
|
||||
"release_url": "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}",
|
||||
"triggered_by": "${{ github.actor }}",
|
||||
"trigger_reason": "${{ github.event_name }}"
|
||||
}
|
||||
github-token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
|
||||
script: |
|
||||
const repository = process.env.REPOSITORY;
|
||||
const [owner, repo] = repository.split('/');
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
event_type: 'update-hashes',
|
||||
client_payload: {
|
||||
source_repository: process.env.SOURCE_REPOSITORY,
|
||||
release_tag: process.env.RELEASE_TAG,
|
||||
release_name: process.env.RELEASE_TAG,
|
||||
release_url: process.env.RELEASE_URL,
|
||||
triggered_by: process.env.ACTOR,
|
||||
trigger_reason: process.env.EVENT_NAME
|
||||
}
|
||||
});
|
||||
env:
|
||||
REPOSITORY: ${{ vars.HOOK_REPO || 'OpenListTeam/OpenList-OpenWRT' }}
|
||||
SOURCE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
|
||||
- name: Log trigger information
|
||||
run: |
|
||||
|
||||
+4
-2
@@ -22,6 +22,7 @@ output/
|
||||
# vendor/
|
||||
/bin/*
|
||||
*.json
|
||||
!renovate.json
|
||||
/build
|
||||
/data/
|
||||
/tmp/
|
||||
@@ -29,6 +30,7 @@ output/
|
||||
/lang/
|
||||
/daemon/
|
||||
/public/dist/*
|
||||
/!public/dist/README.md
|
||||
!/public/dist/README.md
|
||||
|
||||
.VSCodeCounter
|
||||
.VSCodeCounter
|
||||
*.syso
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
@@ -86,6 +86,16 @@ At least 1 approving review is required by reviewers with write access. You can
|
||||
|
||||
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.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
---
|
||||
|
||||
- English | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Dutch](./README_nl.md)
|
||||
- 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)
|
||||
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
- [CODE OF CONDUCT](./CODE_OF_CONDUCT.md)
|
||||
@@ -67,7 +67,7 @@ Thank you for your support and understanding of the OpenList project.
|
||||
- [x] [MediaFire](https://www.mediafire.com)
|
||||
- [x] [Mediatrack](https://www.mediatrack.cn)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
|
||||
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [BaiduNetdisk](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -95,6 +95,7 @@ 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
|
||||
@@ -134,6 +135,14 @@ Please refer to [*Discussions*](https://github.com/OpenListTeam/OpenList/discuss
|
||||
|
||||
[](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.
|
||||
@@ -163,3 +172,4 @@ We sincerely thank the author [Xhofe](https://github.com/Xhofe) of the original
|
||||
Thanks goes to these wonderful people:
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<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, Share)
|
||||
- [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.
|
||||
|
||||
## الراعي
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -14,11 +14,11 @@
|
||||
|
||||
---
|
||||
|
||||
- [English](./README.md) | 中文 | [日本語](./README_ja.md) | [Dutch](./README_nl.md)
|
||||
- [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)
|
||||
|
||||
- [贡献指南](./CONTRIBUTING.md)
|
||||
- [行为准则](./CODE_OF_CONDUCT.md)
|
||||
- [许可证](./LICENSE)
|
||||
- [贡献指南](../CONTRIBUTING.md)
|
||||
- [行为准则](../CODE_OF_CONDUCT.md)
|
||||
- [许可证](../LICENSE)
|
||||
|
||||
## 免责声明
|
||||
|
||||
@@ -67,7 +67,7 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3
|
||||
- [x] [MediaFire](https://www.mediafire.com)
|
||||
- [x] [分秒帧](https://www.mediatrack.cn)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组)
|
||||
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组、分享)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [百度网盘](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -95,6 +95,7 @@ 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] 画廊模式下的图片预览
|
||||
@@ -134,6 +135,14 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3
|
||||
|
||||
[](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) 许可证的开源软件。
|
||||
@@ -148,7 +157,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) 文件。
|
||||
|
||||
## 联系我们
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<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, Share)
|
||||
- [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
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -0,0 +1,136 @@
|
||||
<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, Share)
|
||||
- [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
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -0,0 +1,136 @@
|
||||
<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, Share)
|
||||
- [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
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -14,11 +14,11 @@
|
||||
|
||||
---
|
||||
|
||||
- [English](./README.md) | [中文](./README_cn.md) | 日本語 | [Dutch](./README_nl.md)
|
||||
- [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)
|
||||
|
||||
- [コントリビュート](./CONTRIBUTING.md)
|
||||
- [行動規範](./CODE_OF_CONDUCT.md)
|
||||
- [ライセンス](./LICENSE)
|
||||
- [コントリビュート](../CONTRIBUTING.md)
|
||||
- [行動規範](../CODE_OF_CONDUCT.md)
|
||||
- [ライセンス](../LICENSE)
|
||||
|
||||
## 免責事項
|
||||
|
||||
@@ -66,7 +66,7 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい
|
||||
- [x] Teambition([中国](https://www.teambition.com), [国際](https://us.teambition.com))
|
||||
- [x] [Mediatrack](https://www.mediatrack.cn)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [139yun](https://yun.139.com)(個人、家族、グループ)
|
||||
- [x] [139yun](https://yun.139.com)(個人、家族、グループ、共有)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [BaiduNetdisk](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -94,6 +94,7 @@ 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、コード、テキストなど)
|
||||
@@ -134,6 +135,14 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい
|
||||
|
||||
[](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) ライセンスの下で公開されているオープンソースソフトウェアです。
|
||||
@@ -148,7 +157,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) ファイルをご覧ください。
|
||||
|
||||
## お問い合わせ
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<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, Share)
|
||||
- [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.
|
||||
|
||||
## 후원
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -14,11 +14,11 @@
|
||||
|
||||
---
|
||||
|
||||
- [English](./README.md) | [中文](./README_cn.md) | [日本語](./README_ja.md) | Dutch
|
||||
- [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)
|
||||
|
||||
- [Bijdragen](./CONTRIBUTING.md)
|
||||
- [Gedragscode](./CODE_OF_CONDUCT.md)
|
||||
- [Licentie](./LICENSE)
|
||||
- [Bijdragen](../CONTRIBUTING.md)
|
||||
- [Gedragscode](../CODE_OF_CONDUCT.md)
|
||||
- [Licentie](../LICENSE)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -67,7 +67,7 @@ Dank u voor uw ondersteuning en begrip
|
||||
- [x] [MediaFire](https://www.mediafire.com)
|
||||
- [x] [Mediatrack](https://www.mediatrack.cn)
|
||||
- [x] [ProtonDrive](https://proton.me/drive)
|
||||
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep)
|
||||
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep, Delen)
|
||||
- [x] [YandexDisk](https://disk.yandex.com)
|
||||
- [x] [BaiduNetdisk](http://pan.baidu.com)
|
||||
- [x] [Terabox](https://www.terabox.com/main)
|
||||
@@ -95,6 +95,7 @@ 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
|
||||
@@ -134,6 +135,14 @@ Stel algemene vragen in [*Discussions*](https://github.com/OpenListTeam/OpenList
|
||||
|
||||
[](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.
|
||||
@@ -148,7 +157,7 @@ Stel algemene vragen in [*Discussions*](https://github.com/OpenListTeam/OpenList
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<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, Share)
|
||||
- [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.
|
||||
|
||||
## Спонсор
|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/OpenListTeam/OpenList/graphs/contributors)
|
||||
@@ -97,7 +97,7 @@ AssertStaticBinary() {
|
||||
}
|
||||
|
||||
FetchWebRolling() {
|
||||
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/rolling\"")
|
||||
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/edge\"")
|
||||
pre_release_assets=$(echo "$pre_release_json" | jq -r '.assets[].browser_download_url')
|
||||
|
||||
# There is no lite for rolling
|
||||
@@ -178,7 +178,6 @@ BuildWin7() {
|
||||
}
|
||||
|
||||
BuildDev() {
|
||||
rm -rf .git/
|
||||
mkdir -p "dist"
|
||||
muslflags="$(GetMuslStaticLdflags)"
|
||||
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
@@ -269,7 +268,6 @@ BuildDockerMultiplatform() {
|
||||
}
|
||||
|
||||
BuildRelease() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
BuildWinArm64 ./build/"$appName"-windows-arm64.exe
|
||||
BuildWin7 ./build/"$appName"-windows7
|
||||
@@ -289,7 +287,7 @@ BuildRelease() {
|
||||
BuildLoongGLIBC() {
|
||||
local target_abi="$2"
|
||||
local output_file="$1"
|
||||
local oldWorldGoVersion="1.25.0"
|
||||
local oldWorldGoVersion="1.26.4"
|
||||
local loong_tags
|
||||
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
|
||||
|
||||
@@ -309,13 +307,13 @@ BuildLoongGLIBC() {
|
||||
|
||||
# Download and setup patched Go compiler for old-world
|
||||
if ! curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250821/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||
-o go-loong64-abi1.0.tar.gz; then
|
||||
echo "Error: Failed to download patched Go compiler for old-world ABI1.0"
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
echo "Error output from curl:"
|
||||
curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250821/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||
-o go-loong64-abi1.0.tar.gz || true
|
||||
fi
|
||||
return 1
|
||||
@@ -442,7 +440,6 @@ BuildLoongGLIBC() {
|
||||
}
|
||||
|
||||
BuildReleaseLinuxMusl() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
muslflags="$(GetMuslStaticLdflags)"
|
||||
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
@@ -471,7 +468,6 @@ BuildReleaseLinuxMusl() {
|
||||
}
|
||||
|
||||
BuildReleaseLinuxMuslArm() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
muslflags="$(GetMuslStaticLdflags)"
|
||||
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
@@ -502,7 +498,6 @@ BuildReleaseLinuxMuslArm() {
|
||||
|
||||
|
||||
BuildReleaseAndroid() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build"
|
||||
wget https://dl.google.com/android/repository/android-ndk-r26b-linux.zip
|
||||
unzip android-ndk-r26b-linux.zip
|
||||
@@ -523,7 +518,6 @@ BuildReleaseAndroid() {
|
||||
}
|
||||
|
||||
BuildReleaseFreeBSD() {
|
||||
rm -rf .git/
|
||||
mkdir -p "build/freebsd"
|
||||
|
||||
# Get latest FreeBSD 14.x release version from GitHub
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ type options struct {
|
||||
|
||||
pwd string //de/encrypt password
|
||||
salt string
|
||||
filenameEncryption string //reference drivers\crypt\meta.go Addtion
|
||||
filenameEncryption string //reference drivers\crypt\meta.go Addition
|
||||
dirnameEncryption string
|
||||
filenameEncode string
|
||||
suffix string
|
||||
|
||||
+17
-7
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -31,14 +32,23 @@ func start() {
|
||||
return
|
||||
}
|
||||
}
|
||||
args := os.Args
|
||||
args[1] = "server"
|
||||
args = append(args, "--force-bin-dir")
|
||||
cmd := &exec.Cmd{
|
||||
Path: args[0],
|
||||
Args: args,
|
||||
Env: os.Environ(),
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal("failed to resolve executable path: ", err)
|
||||
}
|
||||
childArgs := append([]string{"server"}, os.Args[2:]...)
|
||||
hasForceBinDir := false
|
||||
for _, arg := range childArgs {
|
||||
if arg == "--force-bin-dir" || strings.HasPrefix(arg, "--force-bin-dir=") {
|
||||
hasForceBinDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasForceBinDir {
|
||||
childArgs = append(childArgs, "--force-bin-dir")
|
||||
}
|
||||
cmd := exec.Command(exe, childArgs...)
|
||||
cmd.Env = os.Environ()
|
||||
stdout, err := os.OpenFile(filepath.Join(filepath.Dir(pidFile), "start.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
log.Fatal(os.Getpid(), ": failed to open start log file:", err)
|
||||
|
||||
@@ -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,9 +68,6 @@ 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+4
-2
@@ -337,9 +337,11 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload
|
||||
for chunk := range chunksCh {
|
||||
var part oss.UploadPart // 出现错误就继续尝试,共尝试3次
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if utils.IsCanceled(ctx) {
|
||||
err = ctx.Err()
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if ossToken, err = d.client.GetOSSToken(); err != nil { // 到时重新获取ossToken
|
||||
errCh <- errors.Wrap(err, "刷新token时出现错误")
|
||||
|
||||
+58
-16
@@ -2,6 +2,7 @@ package _115_open
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
stdpath "path"
|
||||
@@ -14,6 +15,7 @@ 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"
|
||||
@@ -169,15 +171,60 @@ func (d *Open115) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
path = stdpath.Join(d.parentPath, path)
|
||||
resp, err := d.client.GetFolderInfoByPath(ctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, sdk.ErrObjectNotFound) {
|
||||
return d.getFromParent(ctx, path, "")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &Obj{
|
||||
obj := &Obj{
|
||||
Fid: resp.FileID,
|
||||
Fn: resp.FileName,
|
||||
Fc: resp.FileCategory,
|
||||
Sha1: resp.Sha1,
|
||||
Pc: resp.PickCode,
|
||||
}, nil
|
||||
FS: resp.SizeByte,
|
||||
Upt: parseTime(resp.UTime),
|
||||
UpPt: parseTime(resp.PTime),
|
||||
}
|
||||
if !obj.IsDir() && obj.ModTime().Unix() <= 0 {
|
||||
return d.getFromParent(ctx, path, obj.GetID())
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func (d *Open115) getFromParent(ctx context.Context, path, id string) (model.Obj, error) {
|
||||
path = stdpath.Clean(path)
|
||||
parent, name := stdpath.Split(path)
|
||||
parent = stdpath.Clean(parent)
|
||||
parentID := d.GetRootId()
|
||||
if stdpath.Clean(parent) != stdpath.Clean(d.parentPath) {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentInfo, err := d.client.GetFolderInfoByPath(ctx, parent)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sdk.ErrObjectNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
parentObj, err := d.getFromParent(ctx, parent, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentID = parentObj.GetID()
|
||||
} else {
|
||||
parentID = parentInfo.FileID
|
||||
}
|
||||
}
|
||||
files, err := d.List(ctx, &Obj{Fid: parentID, Fc: "0"}, model.ListArgs{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range files {
|
||||
if (id != "" && file.GetID() == id) || (id == "" && file.GetName() == name) {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.ObjectNotFound
|
||||
}
|
||||
|
||||
func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
|
||||
@@ -199,18 +246,15 @@ func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName stri
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Open115) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
||||
func (d *Open115) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
_, err := d.client.Move(ctx, &sdk.MoveReq{
|
||||
FileIDs: srcObj.GetID(),
|
||||
ToCid: dstDir.GetID(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srcObj, nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Open115) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
|
||||
@@ -218,7 +262,7 @@ 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(),
|
||||
FileID: srcObj.GetID(),
|
||||
FileName: newName,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -227,23 +271,21 @@ 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 srcObj, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *Open115) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
||||
func (d *Open115) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
if err := d.WaitLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
_, err := d.client.Copy(ctx, &sdk.CopyReq{
|
||||
PID: dstDir.GetID(),
|
||||
FileID: srcObj.GetID(),
|
||||
NoDupli: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srcObj, nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Open115) Remove(ctx context.Context, obj model.Obj) error {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package _115_open
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func ParseInt64(v json.Number) (int64, error) {
|
||||
i, err := v.Int64()
|
||||
@@ -13,3 +16,8 @@ func ParseInt64(v json.Number) (int64, error) {
|
||||
}
|
||||
return int64(0), err
|
||||
}
|
||||
|
||||
func parseTime(value string) int64 {
|
||||
timestamp, _ := strconv.ParseInt(value, 10, 64)
|
||||
return timestamp
|
||||
}
|
||||
|
||||
@@ -90,9 +90,6 @@ func (d *Pan115Share) Link(ctx context.Context, file model.Obj, args model.LinkA
|
||||
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())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -98,7 +98,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://www.123pan.com/").Get(u_)
|
||||
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://yun.123pan.com/").Get(u_)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+7
-7
@@ -24,9 +24,9 @@ import (
|
||||
// do others that not defined in Driver interface
|
||||
|
||||
const (
|
||||
Api = "https://www.123pan.com/api"
|
||||
AApi = "https://www.123pan.com/a/api"
|
||||
BApi = "https://www.123pan.com/b/api"
|
||||
Api = "https://yun.123pan.com/api"
|
||||
AApi = "https://yun.123pan.com/a/api"
|
||||
BApi = "https://yun.123pan.com/b/api"
|
||||
LoginApi = "https://login.123pan.com/api"
|
||||
MainApi = BApi
|
||||
SignIn = LoginApi + "/user/sign_in"
|
||||
@@ -169,8 +169,8 @@ func (d *Pan123) login() error {
|
||||
}
|
||||
res, err := base.RestyClient.R().
|
||||
SetHeaders(map[string]string{
|
||||
"origin": "https://www.123pan.com",
|
||||
"referer": "https://www.123pan.com/",
|
||||
"origin": "https://yun.123pan.com",
|
||||
"referer": "https://yun.123pan.com/",
|
||||
"user-agent": "Dart/2.19(dart:io)-openlist",
|
||||
"platform": "web",
|
||||
"app-version": "3",
|
||||
@@ -207,8 +207,8 @@ func (d *Pan123) Request(url string, method string, callback base.ReqCallback, r
|
||||
do:
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeaders(map[string]string{
|
||||
"origin": "https://www.123pan.com",
|
||||
"referer": "https://www.123pan.com/",
|
||||
"origin": "https://yun.123pan.com",
|
||||
"referer": "https://yun.123pan.com/",
|
||||
"authorization": "Bearer " + d.AccessToken,
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) openlist-client",
|
||||
"platform": d.Platform,
|
||||
|
||||
@@ -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://www.123pan.com/").Get(u_)
|
||||
res, err := base.NoRedirectClient.R().SetHeader("Referer", "https://yun.123pan.com/").Get(u_)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Api = "https://www.123pan.com/api"
|
||||
AApi = "https://www.123pan.com/a/api"
|
||||
BApi = "https://www.123pan.com/b/api"
|
||||
Api = "https://yun.123pan.com/api"
|
||||
AApi = "https://yun.123pan.com/a/api"
|
||||
BApi = "https://yun.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://www.123pan.com",
|
||||
"referer": "https://www.123pan.com/",
|
||||
"origin": "https://yun.123pan.com",
|
||||
"referer": "https://yun.123pan.com/",
|
||||
"authorization": "Bearer " + d.AccessToken,
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) openlist-client",
|
||||
"platform": "web",
|
||||
|
||||
+257
-138
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/cron"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
"github.com/avast/retry-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,9 @@ type Yun139 struct {
|
||||
Account string
|
||||
ref *Yun139
|
||||
PersonalCloudHost string
|
||||
RootPath string
|
||||
FamilyCloudHost string
|
||||
GroupCloudHost string
|
||||
ProviderRoot string
|
||||
}
|
||||
|
||||
func (d *Yun139) Config() driver.Config {
|
||||
@@ -42,53 +45,62 @@ 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")
|
||||
// Share links may be browsed without account credentials. All other
|
||||
// storage types need an authorization or a supported login fallback.
|
||||
if !d.isShare() {
|
||||
if err := d.validateAndInitCredentials(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := d.refreshToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Query Route Policy
|
||||
var resp QueryRoutePolicyResp
|
||||
_, err = d.requestRoute(base.Json{
|
||||
"userInfo": base.Json{
|
||||
"userType": 1,
|
||||
"accountType": 1,
|
||||
"accountName": d.Account,
|
||||
},
|
||||
"modAddrType": 1,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policyItem := range resp.Data.RoutePolicyList {
|
||||
if policyItem.ModName == "personal" {
|
||||
d.PersonalCloudHost = policyItem.HttpsUrl
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(d.PersonalCloudHost) == 0 {
|
||||
return fmt.Errorf("PersonalCloudHost is empty")
|
||||
}
|
||||
|
||||
d.cron = cron.NewCron(time.Hour * 12)
|
||||
d.cron.Do(func() {
|
||||
if d.Authorization != "" {
|
||||
err := d.refreshToken()
|
||||
if err != nil {
|
||||
log.Errorf("%+v", err)
|
||||
return err
|
||||
}
|
||||
})
|
||||
|
||||
// Query Route Policy
|
||||
var resp QueryRoutePolicyResp
|
||||
_, err = d.requestRoute(base.Json{
|
||||
"userInfo": base.Json{
|
||||
"userType": 1,
|
||||
"accountType": 1,
|
||||
"accountName": d.Account,
|
||||
},
|
||||
"modAddrType": 1,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policyItem := range resp.Data.RoutePolicyList {
|
||||
switch policyItem.ModName {
|
||||
case "personal":
|
||||
d.PersonalCloudHost = policyItem.HttpsUrl
|
||||
case "group":
|
||||
d.GroupCloudHost = policyItem.HttpsUrl
|
||||
case "family":
|
||||
d.FamilyCloudHost = policyItem.HttpsUrl
|
||||
}
|
||||
}
|
||||
if len(d.PersonalCloudHost) == 0 {
|
||||
return fmt.Errorf("PersonalCloudHost is empty")
|
||||
}
|
||||
if d.isGroup() || d.isFamily() {
|
||||
if len(d.GroupCloudHost) == 0 {
|
||||
return fmt.Errorf("GroupCloudHost is empty")
|
||||
}
|
||||
if len(d.FamilyCloudHost) == 0 {
|
||||
return fmt.Errorf("FamilyCloudHost is empty")
|
||||
}
|
||||
}
|
||||
|
||||
d.cron = cron.NewCron(time.Hour * 12)
|
||||
d.cron.Do(func() {
|
||||
err := d.refreshToken()
|
||||
if err != nil {
|
||||
log.Errorf("%+v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -107,15 +119,25 @@ func (d *Yun139) Init(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case MetaFamily:
|
||||
case MetaShare:
|
||||
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)
|
||||
}
|
||||
d.RootFolderID = "root"
|
||||
}
|
||||
_, err := d.familyGetFiles(d.RootFolderID)
|
||||
if len(d.shareEntries()) == 0 {
|
||||
return fmt.Errorf("link_id is empty")
|
||||
}
|
||||
case MetaFamily:
|
||||
// Attempt to obtain data.path as the root via a query and persist it.
|
||||
root, err := d.getFamilyRootPath(d.CloudID)
|
||||
if err != nil || root == "" {
|
||||
return fmt.Errorf("failed to get family root path: %w", err)
|
||||
}
|
||||
d.ProviderRoot = root
|
||||
if len(d.Addition.RootFolderID) == 0 {
|
||||
d.RootFolderID = root
|
||||
op.MustSaveDriverStorage(d)
|
||||
}
|
||||
_, err = d.familyGetFiles(d.RootFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -134,6 +156,19 @@ func (d *Yun139) InitReference(storage driver.Driver) error {
|
||||
return errs.NotSupport
|
||||
}
|
||||
|
||||
func (d *Yun139) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
if !d.isShare() {
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
if path == "/" {
|
||||
return &model.Object{ID: "root", Name: "root", IsFolder: true, Path: "/"}, nil
|
||||
}
|
||||
if obj, err := d.shareGetObj(path); err == nil {
|
||||
return obj, nil
|
||||
}
|
||||
return nil, errs.ObjectNotFound
|
||||
}
|
||||
|
||||
func (d *Yun139) Drop(ctx context.Context) error {
|
||||
if d.cron != nil {
|
||||
d.cron.Stop()
|
||||
@@ -152,12 +187,23 @@ func (d *Yun139) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
|
||||
return d.familyGetFiles(dir.GetID())
|
||||
case MetaGroup:
|
||||
return d.groupGetFiles(dir.GetID())
|
||||
case MetaShare:
|
||||
if dir.GetID() == "root" {
|
||||
return d.shareGetMergedFiles(d.shareRootEntries())
|
||||
}
|
||||
if refs, ok := decodeShareRefs(dir.GetID()); ok {
|
||||
return d.shareGetMergedFiles(refs)
|
||||
}
|
||||
return d.shareGetFilesWithRef(shareRef{LinkID: d.LinkID, NodeID: dir.GetID()}, dir.GetID())
|
||||
default:
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if file.IsDir() {
|
||||
return nil, errs.NotFile
|
||||
}
|
||||
var url string
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
@@ -169,6 +215,16 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
url, err = d.familyGetLink(file.GetID(), file.GetPath())
|
||||
case MetaGroup:
|
||||
url, err = d.groupGetLink(file.GetID(), file.GetPath())
|
||||
case MetaShare:
|
||||
if refs, ok := decodeShareRefs(file.GetID()); ok && len(refs) > 0 {
|
||||
return d.shareGetLinkWithRef(refs[0], refs[0].NodeID, args.Type)
|
||||
}
|
||||
fallbackRef := shareRef{LinkID: d.LinkID, NodeID: "root"}
|
||||
if entries := d.shareEntries(); len(entries) > 0 {
|
||||
fallbackRef.LinkID = entries[0].LinkID
|
||||
fallbackRef.Password = entries[0].Password
|
||||
}
|
||||
return d.shareGetLinkWithRef(fallbackRef, file.GetID(), args.Type)
|
||||
default:
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
@@ -179,6 +235,9 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
|
||||
}
|
||||
|
||||
func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -212,7 +271,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
|
||||
"accountType": 1,
|
||||
},
|
||||
"docLibName": dirName,
|
||||
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
|
||||
"path": d.dirPath(parentDir),
|
||||
}
|
||||
pathname := "/orchestration/familyCloud-rebuild/cloudCatalog/v1.0/createCloudDoc"
|
||||
_, err = d.post(pathname, data, nil)
|
||||
@@ -225,7 +284,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
|
||||
},
|
||||
"groupID": d.CloudID,
|
||||
"parentFileId": parentDir.GetID(),
|
||||
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
|
||||
"path": d.dirPath(parentDir),
|
||||
}
|
||||
pathname := "/orchestration/group-rebuild/catalog/v1.0/createGroupCatalog"
|
||||
_, err = d.post(pathname, data, nil)
|
||||
@@ -236,6 +295,9 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
|
||||
}
|
||||
|
||||
func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
||||
if d.isShare() {
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
data := base.Json{
|
||||
@@ -310,9 +372,9 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
|
||||
var contentList []string
|
||||
var catalogList []string
|
||||
if srcObj.IsDir() {
|
||||
catalogList = append(catalogList, path.Join(srcObj.GetPath(), srcObj.GetID()))
|
||||
catalogList = append(catalogList, d.dirPath(srcObj))
|
||||
} else {
|
||||
contentList = append(contentList, path.Join(srcObj.GetPath(), srcObj.GetID()))
|
||||
contentList = append(contentList, d.dirPath(srcObj))
|
||||
}
|
||||
|
||||
body := base.Json{
|
||||
@@ -324,7 +386,7 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
|
||||
"contentList": contentList,
|
||||
"destCatalogID": dstDir.GetID(),
|
||||
"destGroupID": d.CloudID,
|
||||
"destPath": path.Join(dstDir.GetPath(), dstDir.GetID()),
|
||||
"destPath": d.dirPath(dstDir),
|
||||
"destType": 0,
|
||||
"srcGroupID": d.CloudID,
|
||||
"srcType": 0,
|
||||
@@ -347,6 +409,9 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
|
||||
}
|
||||
|
||||
func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -425,7 +490,7 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
|
||||
},
|
||||
"docLibName": newName,
|
||||
"docLibraryID": srcObj.GetID(),
|
||||
"path": path.Join(srcObj.GetPath(), srcObj.GetID()),
|
||||
"path": d.dirPath(srcObj),
|
||||
}
|
||||
var resp ModifyCloudDocV2Resp
|
||||
_, err = d.andAlbumRequest(pathname, data, &resp)
|
||||
@@ -456,6 +521,9 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
|
||||
}
|
||||
|
||||
func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
var err error
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
@@ -525,6 +593,9 @@ func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
}
|
||||
|
||||
func (d *Yun139) Remove(ctx context.Context, obj model.Obj) error {
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
data := base.Json{
|
||||
@@ -615,8 +686,25 @@ func (d *Yun139) getPartSize(size int64) int64 {
|
||||
}
|
||||
|
||||
func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonalNew:
|
||||
if d.isShare() {
|
||||
return errs.NotImplement
|
||||
}
|
||||
// PersonalNew 以及 Group/Family 在非旧流模式时走新上传路径
|
||||
if d.Addition.Type == MetaPersonalNew ||
|
||||
((d.isGroup() || d.isFamily()) && !d.UseOldStreamUpload) {
|
||||
var createPath, getUploadUrlPath, completePath string
|
||||
if d.isGroup() || d.isFamily() {
|
||||
// 家庭云和共享群共用同一套新上传 API
|
||||
createPath = "/dynamic/file/create"
|
||||
getUploadUrlPath = "/dynamic/file/getUploadUrl"
|
||||
completePath = "/dynamic/file/complete"
|
||||
} else {
|
||||
// MetaPersonalNew
|
||||
createPath = "/file/create"
|
||||
getUploadUrlPath = "/file/getUploadUrl"
|
||||
completePath = "/file/complete"
|
||||
}
|
||||
|
||||
var err error
|
||||
fullHash := stream.GetHash().GetHash(utils.SHA256)
|
||||
if len(fullHash) != utils.SHA256.Width {
|
||||
@@ -671,9 +759,22 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
"type": "file",
|
||||
"fileRenameMode": "auto_rename",
|
||||
}
|
||||
pathname := "/file/create"
|
||||
// 家庭云和共享群需要额外的参数
|
||||
if d.isGroup() || d.isFamily() {
|
||||
if d.CloudID == "" {
|
||||
return fmt.Errorf("cloud_id is required for group/family upload")
|
||||
}
|
||||
data["groupId"] = d.CloudID
|
||||
if d.isGroup() {
|
||||
data["groupType"] = 2
|
||||
} else if d.isFamily() {
|
||||
data["groupType"] = 1
|
||||
}
|
||||
data["catalogType"] = 3
|
||||
data["seqNo"] = random.String(32)
|
||||
}
|
||||
var resp PersonalUploadResp
|
||||
_, err = d.personalPost(pathname, data, &resp)
|
||||
_, err = d.newPost(createPath, data, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -690,10 +791,19 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
if resp.Data.PartInfos != nil {
|
||||
// Progress
|
||||
p := driver.NewProgress(size, up)
|
||||
|
||||
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
|
||||
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
|
||||
Ctx: ctx,
|
||||
Reader: rateLimited,
|
||||
Obj: &model.Object{Size: size},
|
||||
}, int(partSize), &up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 先上传前100个分片
|
||||
err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, rateLimited, p)
|
||||
err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, ss, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -711,13 +821,12 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
"accountType": 1,
|
||||
},
|
||||
}
|
||||
pathname := "/file/getUploadUrl"
|
||||
var moreresp PersonalUploadUrlResp
|
||||
_, err = d.personalPost(pathname, moredata, &moreresp)
|
||||
_, err = d.newPost(getUploadUrlPath, moredata, &moreresp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, rateLimited, p)
|
||||
err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, ss, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -730,7 +839,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
"fileId": resp.Data.FileId,
|
||||
"uploadId": resp.Data.UploadId,
|
||||
}
|
||||
_, err = d.personalPost("/file/complete", data, nil)
|
||||
// 家庭云和共享群需要额外的参数
|
||||
if d.isGroup() || d.isFamily() {
|
||||
data["groupId"] = d.CloudID
|
||||
}
|
||||
_, err = d.newPost(completePath, data, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -775,11 +888,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case MetaPersonal:
|
||||
fallthrough
|
||||
case MetaGroup:
|
||||
fallthrough
|
||||
case MetaFamily:
|
||||
}
|
||||
|
||||
// 旧上传路径
|
||||
switch d.Addition.Type {
|
||||
case MetaPersonal, MetaGroup, MetaFamily:
|
||||
// 处理冲突
|
||||
// 获取文件列表
|
||||
files, err := d.List(ctx, dstDir, model.ListArgs{})
|
||||
@@ -826,11 +939,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
},
|
||||
}
|
||||
pathname := "/orchestration/personalCloud/uploadAndDownload/v1.0/pcUploadFileRequest"
|
||||
if d.isFamily() || d.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() || d.isGroup() {
|
||||
uploadPath := d.dirPath(dstDir)
|
||||
// 共享群的根目录上传路径为 0
|
||||
if d.isGroup() && dstDir.GetID() == d.RootFolderID {
|
||||
uploadPath = "0"
|
||||
}
|
||||
data = d.newJson(base.Json{
|
||||
"fileCount": 1,
|
||||
@@ -858,56 +971,86 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
|
||||
}
|
||||
|
||||
size := stream.GetSize()
|
||||
partSize := d.getPartSize(size)
|
||||
|
||||
// Progress
|
||||
p := driver.NewProgress(size, up)
|
||||
partSize := d.getPartSize(size)
|
||||
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
|
||||
|
||||
// StreamSectionReader for per-chunk buffering and retry
|
||||
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
|
||||
Ctx: ctx,
|
||||
Reader: rateLimited,
|
||||
Obj: &model.Object{Size: size},
|
||||
}, int(partSize), &up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
part := int64(1)
|
||||
if size > partSize {
|
||||
part = (size + partSize - 1) / partSize
|
||||
}
|
||||
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
|
||||
for i := int64(0); i < part; i++ {
|
||||
if utils.IsCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
start := i * partSize
|
||||
byteSize := min(size-start, partSize)
|
||||
|
||||
limitReader := io.LimitReader(rateLimited, byteSize)
|
||||
// Update Progress
|
||||
r := io.TeeReader(limitReader, p)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resp.Data.UploadResult.RedirectionURL, r)
|
||||
if err != nil {
|
||||
return err
|
||||
rd, getErr := ss.GetSectionReader(start, byteSize)
|
||||
if getErr != nil {
|
||||
return getErr
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
|
||||
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
|
||||
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
|
||||
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
|
||||
req.Header.Set("rangeType", "0")
|
||||
req.ContentLength = byteSize
|
||||
|
||||
res, err := base.HttpClient.Do(req)
|
||||
err = retry.Do(
|
||||
func() error {
|
||||
if _, err := rd.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, resp.Data.UploadResult.RedirectionURL,
|
||||
io.TeeReader(rd, p))
|
||||
if reqErr != nil {
|
||||
return reqErr
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
|
||||
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
|
||||
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
|
||||
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
|
||||
req.Header.Set("rangeType", "0")
|
||||
req.ContentLength = byteSize
|
||||
|
||||
res, doErr := base.HttpClient.Do(req)
|
||||
if doErr != nil {
|
||||
return doErr
|
||||
}
|
||||
defer res.Body.Close()
|
||||
bodyBytes, readErr := io.ReadAll(res.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("error reading response body: %v", readErr)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var result InterLayerUploadResult
|
||||
xmlErr := xml.Unmarshal(bodyBytes, &result)
|
||||
if xmlErr != nil {
|
||||
return fmt.Errorf("error parsing XML: %v", xmlErr)
|
||||
}
|
||||
if result.ResultCode != 0 {
|
||||
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
retry.Context(ctx),
|
||||
retry.Attempts(3),
|
||||
retry.DelayType(retry.BackOffDelay),
|
||||
retry.Delay(time.Second),
|
||||
)
|
||||
ss.FreeSectionReader(rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
res.Body.Close()
|
||||
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
var result InterLayerUploadResult
|
||||
err = xml.Unmarshal(bodyBytes, &result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing XML: %v", err)
|
||||
}
|
||||
if result.ResultCode != 0 {
|
||||
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
@@ -944,38 +1087,14 @@ func (d *Yun139) GetDetails(ctx context.Context) (*model.StorageDetails, error)
|
||||
if d.UserDomainID == "" {
|
||||
return nil, errs.NotImplement
|
||||
}
|
||||
var total, used int64
|
||||
if d.isFamily() {
|
||||
diskInfo, err := d.getFamilyDiskInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalMb, err := strconv.ParseInt(diskInfo.Data.DiskSize, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed convert disk size into integer: %+v", err)
|
||||
}
|
||||
usedMb, err := strconv.ParseInt(diskInfo.Data.UsedSize, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed convert used size into integer: %+v", err)
|
||||
}
|
||||
total = totalMb * 1024 * 1024
|
||||
used = usedMb * 1024 * 1024
|
||||
} else {
|
||||
diskInfo, err := d.getPersonalDiskInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalMb, err := strconv.ParseInt(diskInfo.Data.DiskSize, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed convert disk size into integer: %+v", err)
|
||||
}
|
||||
freeMb, err := strconv.ParseInt(diskInfo.Data.FreeDiskSize, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed convert free size into integer: %+v", err)
|
||||
}
|
||||
total = totalMb * 1024 * 1024
|
||||
used = total - (freeMb * 1024 * 1024)
|
||||
detail, err := d.getDiskQuotaDetail(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := detail.Data.DiskSize * utils.MB
|
||||
used := (detail.Data.DiskSize - detail.Data.FreeDiskSize) * utils.MB
|
||||
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: total,
|
||||
|
||||
+8
-5
@@ -7,17 +7,20 @@ import (
|
||||
|
||||
type Addition struct {
|
||||
//Account string `json:"account" required:"true"`
|
||||
Authorization string `json:"authorization" type:"text" required:"true"`
|
||||
Username string `json:"username" required:"true"`
|
||||
Password string `json:"password" required:"true" secret:"true"`
|
||||
MailCookies string `json:"mail_cookies" required:"true" type:"text" help:"Cookies from mail.139.com used for login authentication."`
|
||||
Authorization string `json:"authorization" type:"text" help:"Authorization can be used alone. If empty, use username + password; mail_cookies is optional and will be established/updated automatically. Existing mail_cookies can also be used alone for fast login."`
|
||||
Username string `json:"username" help:"Use together with password when Authorization is empty. mail_cookies may be left empty on the first login."`
|
||||
Password string `json:"password" secret:"true" help:"Use together with username when Authorization is empty. mail_cookies may be left empty on the first login."`
|
||||
SmsCode string `json:"sms_code" secret:"true" help:"Fill this only after OpenList reports that a 139 Mail SMS verification code was sent, then save the storage again."`
|
||||
MailCookies string `json:"mail_cookies" type:"text" help:"Optional cookies from mail.10086.cn. Leave empty for a first username/password login; cookies created or updated by password/SMS login are persisted and reused as device context. Existing cookies may also be used alone for fast login."`
|
||||
driver.RootID
|
||||
Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"`
|
||||
Type string `json:"type" type:"select" options:"personal_new,family,group,personal,share" default:"personal_new"`
|
||||
LinkID string `json:"link_id" type:"text" help:"Multiple shares are separated by commas or new lines. Use link_id#password for password-protected shares."`
|
||||
CloudID string `json:"cloud_id"`
|
||||
UserDomainID string `json:"user_domain_id" help:"ud_id in Cookie, fill in to show disk usage"`
|
||||
CustomUploadPartSize int64 `json:"custom_upload_part_size" type:"number" default:"0" help:"0 for auto"`
|
||||
ReportRealSize bool `json:"report_real_size" type:"bool" default:"true" help:"Enable to report the real file size during upload"`
|
||||
UseLargeThumbnail bool `json:"use_large_thumbnail" type:"bool" default:"false" help:"Enable to use large thumbnail for images"`
|
||||
UseOldStreamUpload bool `json:"use_old_stream_upload" type:"bool" default:"false" help:"Enable to use old stream upload method (not support rapid upload)"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package _139
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShareEntriesAndRefEncoding(t *testing.T) {
|
||||
d := &Yun139{Addition: Addition{
|
||||
Type: MetaShare,
|
||||
LinkID: "share-a,share-b,share-c#pass",
|
||||
}}
|
||||
|
||||
entries := d.shareEntries()
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 share entries, got %d", len(entries))
|
||||
}
|
||||
if entries[2].LinkID != "share-c" || entries[2].Password != "pass" {
|
||||
t.Fatalf("unexpected password share entry: %+v", entries[2])
|
||||
}
|
||||
|
||||
refs := []shareRef{
|
||||
{LinkID: entries[0].LinkID, Password: entries[0].Password, NodeID: "root-a"},
|
||||
{LinkID: entries[2].LinkID, Password: entries[2].Password, NodeID: "root-b"},
|
||||
}
|
||||
encoded := encodeShareRefs(refs)
|
||||
decoded, ok := decodeShareRefs(encoded)
|
||||
if !ok || len(decoded) != len(refs) {
|
||||
t.Fatalf("failed to decode merged share refs: %q", encoded)
|
||||
}
|
||||
for i := range refs {
|
||||
if decoded[i] != refs[i] {
|
||||
t.Fatalf("unexpected decoded ref at %d: %+v", i, decoded[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-12
@@ -9,6 +9,7 @@ const (
|
||||
MetaFamily string = "family"
|
||||
MetaGroup string = "group"
|
||||
MetaPersonalNew string = "personal_new"
|
||||
MetaShare string = "share"
|
||||
)
|
||||
|
||||
type BaseResp struct {
|
||||
@@ -285,6 +286,56 @@ type PersonalUploadUrlResp struct {
|
||||
}
|
||||
}
|
||||
|
||||
type ShareCatalog struct {
|
||||
CaID string `json:"caId"`
|
||||
CaName string `json:"caName"`
|
||||
UdTime string `json:"udTime"`
|
||||
}
|
||||
|
||||
type ShareContent struct {
|
||||
CoID string `json:"coId"`
|
||||
CoName string `json:"coName"`
|
||||
CoSize int64 `json:"coSize"`
|
||||
CoType int `json:"coType"`
|
||||
UdTime string `json:"udTime"`
|
||||
CoPath string `json:"coPath"`
|
||||
PresentURL string `json:"presentURL"`
|
||||
DownloadURL string `json:"downloadURL"`
|
||||
}
|
||||
|
||||
type ShareListResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
LKName string `json:"lkName"`
|
||||
Passwd string `json:"password"`
|
||||
CaLst []ShareCatalog `json:"caLst"`
|
||||
CoLst []ShareContent `json:"coLst"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ShareContentInfo struct {
|
||||
PresentURL string `json:"presentURL"`
|
||||
DownloadURL string `json:"cdnDownLoadUrl"`
|
||||
}
|
||||
|
||||
type ShareDownloadResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
DownloadURL string `json:"downloadURL"`
|
||||
RedrURL string `json:"redrUrl"`
|
||||
ExtInfo struct {
|
||||
CDNDownloadURL string `json:"cdnDownloadUrl"`
|
||||
} `json:"extInfo"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ShareContentInfoResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
ContentInfo ShareContentInfo `json:"contentInfo"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type QueryRoutePolicyResp struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code"`
|
||||
@@ -313,20 +364,16 @@ type RefreshTokenResp struct {
|
||||
Desc string `xml:"desc"`
|
||||
}
|
||||
|
||||
type PersonalDiskInfoResp struct {
|
||||
type DiskQuotaDetail struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
FreeDiskSize string `json:"freeDiskSize"`
|
||||
DiskSize string `json:"diskSize"`
|
||||
IsInfinitePicStorage *bool `json:"isInfinitePicStorage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type FamilyDiskInfoResp struct {
|
||||
BaseResp
|
||||
Data struct {
|
||||
UsedSize string `json:"usedSize"`
|
||||
DiskSize string `json:"diskSize"`
|
||||
FreeDiskSize int64 `json:"freeDiskSize"`
|
||||
DiskSize int64 `json:"diskSize"`
|
||||
// QuotaList []struct {
|
||||
// DriveType int `json:"driveType"`
|
||||
// DriveName string `json:"driveName"`
|
||||
// UsedSize int64 `json:"usedSize"`
|
||||
// } `json:"quotaList"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
|
||||
+826
-137
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
package _139
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func useRetryingTestClient(t *testing.T) {
|
||||
t.Helper()
|
||||
oldClient := base.RestyClient
|
||||
base.RestyClient = resty.New().SetRetryCount(3)
|
||||
t.Cleanup(func() {
|
||||
base.RestyClient = oldClient
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeMailCookiesPreservesExistingOrderAndAppendsNewNames(t *testing.T) {
|
||||
got := mergeMailCookieHeader("z=zv; behaviorid=b; Os_SSo_Sid=old", []*http.Cookie{
|
||||
{Name: "RMKEY", Value: "rm"},
|
||||
{Name: "Os_SSo_Sid", Value: "sid"},
|
||||
{Name: "a", Value: "av"},
|
||||
})
|
||||
want := "z=zv;behaviorid=b;Os_SSo_Sid=sid;RMKEY=rm;a=av"
|
||||
if got != want {
|
||||
t.Fatalf("mergeMailCookieHeader() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFastLoginCookies(t *testing.T) {
|
||||
sid, rmkey := extractFastLoginCookies("RMKEY=rm; Os_SSo_Sid=sid")
|
||||
if sid != "sid" || rmkey != "rm" {
|
||||
t.Fatalf("extractFastLoginCookies() = %q, %q; want sid, rm", sid, rmkey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
d Yun139
|
||||
want credentialState
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "authorization",
|
||||
d: Yun139{Addition: Addition{Authorization: " auth "}},
|
||||
want: credentialStateAuthorization,
|
||||
},
|
||||
{
|
||||
name: "full login",
|
||||
d: Yun139{Addition: Addition{
|
||||
MailCookies: "RMKEY=rm; Os_SSo_Sid=sid",
|
||||
Username: "user",
|
||||
Password: "password",
|
||||
}},
|
||||
want: credentialStateFullLogin,
|
||||
},
|
||||
{
|
||||
name: "full login without initial cookies",
|
||||
d: Yun139{Addition: Addition{
|
||||
Username: "user",
|
||||
Password: "password",
|
||||
}},
|
||||
want: credentialStateFullLogin,
|
||||
},
|
||||
{
|
||||
name: "cookies only",
|
||||
d: Yun139{Addition: Addition{MailCookies: "RMKEY=rm; Os_SSo_Sid=sid"}},
|
||||
want: credentialStateCookiesOnly,
|
||||
},
|
||||
{
|
||||
name: "partial password login",
|
||||
d: Yun139{Addition: Addition{Username: "user"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "missing credentials",
|
||||
d: Yun139{},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid cookie",
|
||||
d: Yun139{Addition: Addition{MailCookies: "invalid-cookie"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "authorization with basic prefix",
|
||||
d: Yun139{Addition: Addition{Authorization: "Basic abc"}},
|
||||
err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.d.credentialState()
|
||||
if tt.err {
|
||||
if err == nil {
|
||||
t.Fatal("credentialState() expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("credentialState() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationLoginObtainsAuthorization(t *testing.T) {
|
||||
if os.Getenv("OPENLIST_139_INTEGRATION") != "1" {
|
||||
t.Skip("set OPENLIST_139_INTEGRATION=1 to run live 139Yun login checks")
|
||||
}
|
||||
base.RestyClient = resty.New().
|
||||
SetHeader("user-agent", base.UserAgent).
|
||||
SetRetryCount(3).
|
||||
SetRetryResetReaders(true).
|
||||
SetTimeout(30 * time.Second)
|
||||
|
||||
username := os.Getenv("OPENLIST_139_USERNAME")
|
||||
password := os.Getenv("OPENLIST_139_PASSWORD")
|
||||
mailCookies := os.Getenv("OPENLIST_139_MAIL_COOKIES")
|
||||
authorization := strings.TrimSpace(os.Getenv("OPENLIST_139_AUTHORIZATION"))
|
||||
|
||||
if authorization != "" {
|
||||
t.Run("authorization", func(t *testing.T) {
|
||||
d := Yun139{Addition: Addition{Authorization: authorization}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if state != credentialStateAuthorization {
|
||||
t.Fatalf("credentialState() = %v, want authorization", state)
|
||||
}
|
||||
if d.Authorization == "" || strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
|
||||
t.Fatal("authorization should be present without Basic prefix")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if mailCookies == "" {
|
||||
t.Fatal("OPENLIST_139_MAIL_COOKIES is required")
|
||||
}
|
||||
|
||||
runFastLogin := func(mailCookies string) error {
|
||||
d := Yun139{Addition: Addition{MailCookies: mailCookies}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("credentialState() error: %w", err)
|
||||
}
|
||||
if state != credentialStateCookiesOnly {
|
||||
return fmt.Errorf("credentialState() = %v, want cookies only", state)
|
||||
}
|
||||
sid, rmkey := extractFastLoginCookies(d.MailCookies)
|
||||
if sid == "" || rmkey == "" {
|
||||
return fmt.Errorf("mail cookies are missing Os_SSo_Sid or RMKEY")
|
||||
}
|
||||
token, err := d.step2_get_single_token(sid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step2_get_single_token() error: %w", err)
|
||||
}
|
||||
auth, err := d.step3_third_party_login(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step3_third_party_login() error: %w", err)
|
||||
}
|
||||
if auth == "" {
|
||||
return fmt.Errorf("authorization is empty after fast login")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
t.Fatal("OPENLIST_139_USERNAME and OPENLIST_139_PASSWORD are required for password fallback")
|
||||
}
|
||||
|
||||
var refreshedMailCookies string
|
||||
var generatedAuthorization string
|
||||
t.Run("password login fallback", func(t *testing.T) {
|
||||
d := Yun139{Addition: Addition{
|
||||
MailCookies: mailCookies,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if state != credentialStateFullLogin {
|
||||
t.Fatalf("credentialState() = %v, want full login", state)
|
||||
}
|
||||
passId, err := d.step1_password_login()
|
||||
if err != nil {
|
||||
t.Fatalf("step1_password_login() error: %v", err)
|
||||
}
|
||||
token, err := d.step2_get_single_token(passId)
|
||||
if err != nil {
|
||||
t.Fatalf("step2_get_single_token() error: %v", err)
|
||||
}
|
||||
auth, err := d.step3_third_party_login(token)
|
||||
if err != nil {
|
||||
t.Fatalf("step3_third_party_login() error: %v", err)
|
||||
}
|
||||
d.Authorization = auth
|
||||
if auth == "" || d.Authorization == "" {
|
||||
t.Fatal("authorization is empty after password login")
|
||||
}
|
||||
generatedAuthorization = auth
|
||||
refreshedMailCookies = d.MailCookies
|
||||
})
|
||||
|
||||
t.Run("authorization generated by password login", func(t *testing.T) {
|
||||
if generatedAuthorization == "" {
|
||||
t.Fatal("password login did not generate authorization")
|
||||
}
|
||||
d := Yun139{Addition: Addition{Authorization: generatedAuthorization}}
|
||||
state, err := d.credentialState()
|
||||
if err != nil {
|
||||
t.Fatalf("credentialState() unexpected error: %v", err)
|
||||
}
|
||||
if state != credentialStateAuthorization {
|
||||
t.Fatalf("credentialState() = %v, want authorization", state)
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
|
||||
t.Fatal("authorization should not include Basic prefix")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mail cookies fast login from input", func(t *testing.T) {
|
||||
sid, rmkey := extractFastLoginCookies(mailCookies)
|
||||
if sid == "" || rmkey == "" {
|
||||
t.Skip("input mail cookies are missing Os_SSo_Sid or RMKEY")
|
||||
}
|
||||
if err := runFastLogin(mailCookies); err != nil {
|
||||
t.Skipf("input mail cookies are stale or unusable: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mail cookies fast login after password login", func(t *testing.T) {
|
||||
if refreshedMailCookies == "" {
|
||||
t.Fatal("password login did not refresh mail cookies")
|
||||
}
|
||||
if err := runFastLogin(refreshedMailCookies); err != nil {
|
||||
t.Fatalf("refreshed mail cookies fast login failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidAuthorizationDoesNotUseCookieFastLogin(t *testing.T) {
|
||||
d := Yun139{Addition: Addition{
|
||||
Authorization: "not-base64",
|
||||
MailCookies: "Os_SSo_Sid=sid; RMKEY=rmkey",
|
||||
}}
|
||||
|
||||
err := d.refreshToken()
|
||||
if err == nil || !strings.Contains(err.Error(), "password login failed") {
|
||||
t.Fatalf("refreshToken() error = %v, want password login fallback error", err)
|
||||
}
|
||||
if d.Authorization != "not-base64" {
|
||||
t.Fatalf("Authorization = %q, want original invalid value retained", d.Authorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSSceneForRisk(t *testing.T) {
|
||||
tests := map[string]int{
|
||||
"S025": 1,
|
||||
"S035": 1,
|
||||
"PML401010062": 2,
|
||||
"MW0016": 4,
|
||||
}
|
||||
for riskCode, want := range tests {
|
||||
got, ok := smsSceneForRisk(riskCode)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("smsSceneForRisk(%q) = %d, %t; want %d, true", riskCode, got, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := smsSceneForRisk("PICTURE_ONLY"); ok {
|
||||
t.Fatal("smsSceneForRisk() accepted unsupported risk code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSMSVerificationCode(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
if r.URL.Query().Get("func") != "login:sendSmsCodeByScene" {
|
||||
t.Errorf("func = %q", r.URL.Query().Get("func"))
|
||||
}
|
||||
if !strings.Contains(string(body), `<string name="scene">1</string>`) {
|
||||
t.Errorf("request body = %s", body)
|
||||
}
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "device=fingerprint") {
|
||||
t.Errorf("Cookie = %q", r.Header.Get("Cookie"))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "challenge", Value: "sms"})
|
||||
_, _ = io.WriteString(w, `{"code":"S_OK"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailSMSURL
|
||||
mailSMSURL = server.URL
|
||||
defer func() { mailSMSURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000", MailCookies: "device=fingerprint"}}
|
||||
if err := d.sendSMSVerificationCode("S025"); err != nil {
|
||||
t.Fatalf("sendSMSVerificationCode() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(d.MailCookies, "challenge=sms") {
|
||||
t.Fatalf("MailCookies = %q", d.MailCookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSMSVerificationCodeStopsAtPictureChallenge(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"code":"PML401010021"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailSMSURL
|
||||
mailSMSURL = server.URL
|
||||
defer func() { mailSMSURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000"}}
|
||||
err := d.sendSMSVerificationCode("S025")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires picture verification") {
|
||||
t.Fatalf("sendSMSVerificationCode() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySMSCode(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
if r.URL.Query().Get("func") != "/login/inlogin.action" {
|
||||
t.Errorf("func = %q", r.URL.Query().Get("func"))
|
||||
}
|
||||
wantHash := sha1Hash("fetion.com.cn:123456")
|
||||
if !strings.Contains(string(body), `<string name="loginPassword">`+wantHash+`</string>`) {
|
||||
t.Errorf("request body does not contain SMS code hash: %s", body)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rmkey"})
|
||||
_, _ = io.WriteString(w, `{"code":"S_OK","var":{"loginSuccessUrl":"https://mail.10086.cn/?sid=sms-sid"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailSMSURL
|
||||
mailSMSURL = server.URL
|
||||
defer func() { mailSMSURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{
|
||||
Username: "18800000000",
|
||||
SmsCode: "123456",
|
||||
MailCookies: "challenge=sms",
|
||||
}}
|
||||
sid, err := d.verifySMSCode("S025")
|
||||
if err != nil {
|
||||
t.Fatalf("verifySMSCode() error = %v", err)
|
||||
}
|
||||
if sid != "sms-sid" {
|
||||
t.Fatalf("sid = %q", sid)
|
||||
}
|
||||
if d.SmsCode != "" {
|
||||
t.Fatalf("SmsCode = %q, want cleared", d.SmsCode)
|
||||
}
|
||||
if !strings.Contains(d.MailCookies, "RMKEY=new-rmkey") {
|
||||
t.Fatalf("MailCookies = %q", d.MailCookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLoginWithoutInitialCookiesStopsAtRedirectAndPersistsCookies(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
var loginRequests, redirectRequests int
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/Login/Login.ashx":
|
||||
loginRequests++
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rm"})
|
||||
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "new-sid"})
|
||||
w.Header().Set("Location", server.URL+"/appmail?sid=single-sid")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
case "/appmail":
|
||||
redirectRequests++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailPasswordURL
|
||||
mailPasswordURL = server.URL + "/Login/Login.ashx"
|
||||
defer func() { mailPasswordURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password"}}
|
||||
sid, err := d.step1_password_login()
|
||||
if err != nil || sid != "single-sid" {
|
||||
t.Fatalf("sid=%q err=%v", sid, err)
|
||||
}
|
||||
if loginRequests != 1 || redirectRequests != 0 {
|
||||
t.Fatalf("login/redirect requests=%d/%d, want 1/0", loginRequests, redirectRequests)
|
||||
}
|
||||
if !strings.Contains(d.MailCookies, "RMKEY=new-rm") || !strings.Contains(d.MailCookies, "Os_SSo_Sid=new-sid") {
|
||||
t.Fatalf("response cookies were not persisted: %q", d.MailCookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLoginReusesRawRefreshedCookies(t *testing.T) {
|
||||
useRetryingTestClient(t)
|
||||
|
||||
var n int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n++
|
||||
cookie := r.Header.Get("Cookie")
|
||||
if !strings.Contains(cookie, "JSESSIONID=stale") || !strings.Contains(cookie, "behaviorid=device") {
|
||||
t.Errorf("login %d lost raw cookie context: %q", n, cookie)
|
||||
}
|
||||
if n == 1 {
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-1"})
|
||||
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-1"})
|
||||
w.Header().Set("Location", "https://mail.10086.cn/?sid=first")
|
||||
} else {
|
||||
if !strings.Contains(cookie, "RMKEY=rm-1") || !strings.Contains(cookie, "Os_SSo_Sid=sid-1") {
|
||||
t.Errorf("second login did not reuse first refreshed cookies: %q", cookie)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-2"})
|
||||
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-2"})
|
||||
w.Header().Set("Location", "https://mail.10086.cn/?sid=second")
|
||||
}
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := mailPasswordURL
|
||||
mailPasswordURL = server.URL
|
||||
defer func() { mailPasswordURL = oldURL }()
|
||||
|
||||
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password", MailCookies: "Os_SSo_Sid=old; RMKEY=old; JSESSIONID=stale; behaviorid=device"}}
|
||||
if sid, err := d.step1_password_login(); err != nil || sid != "first" {
|
||||
t.Fatalf("first login sid=%q err=%v", sid, err)
|
||||
}
|
||||
refreshed := d.MailCookies
|
||||
if !strings.Contains(refreshed, "RMKEY=rm-1") || !strings.Contains(refreshed, "Os_SSo_Sid=sid-1") {
|
||||
t.Fatalf("first login did not persist refreshed cookies: %q", refreshed)
|
||||
}
|
||||
d.Authorization = ""
|
||||
d.MailCookies = refreshed
|
||||
if sid, err := d.step1_password_login(); err != nil || sid != "second" {
|
||||
t.Fatalf("second login sid=%q err=%v", sid, err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("login count=%d, want 2", n)
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -5,12 +5,15 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
|
||||
func clientSuffix() map[string]string {
|
||||
@@ -57,7 +60,13 @@ func timestamp() int64 {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
func (t *Time) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Unmarshal([]byte(s))
|
||||
}
|
||||
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
@@ -72,10 +81,19 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
}
|
||||
func (t *Time) Unmarshal(b []byte) error {
|
||||
bs := strings.Trim(string(b), "\"")
|
||||
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
|
||||
bs = strings.ReplaceAll(bs, "\u202f", " ")
|
||||
bs = strings.ReplaceAll(bs, "\u00a0", " ")
|
||||
var v time.Time
|
||||
var err error
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
|
||||
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
|
||||
for _, s := range []string{bs, bs + " +08"} {
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, s, utils.CNLoc)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package _189_tv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeUnmarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Time
|
||||
}{
|
||||
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
|
||||
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
|
||||
}
|
||||
if !tt.want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
|
||||
t.Fatal("Unmarshal accepted an invalid time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
|
||||
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
|
||||
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalXML(t *testing.T) {
|
||||
var tm Time
|
||||
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33 PM +08</time>`), &tm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
|
||||
if !want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -184,6 +184,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
}
|
||||
fullUrl += "/listFiles.action"
|
||||
|
||||
pageSize := 130 // 每一页返回的文件数量
|
||||
res := make([]model.Obj, 0, 130)
|
||||
for pageNum := 1; ; pageNum++ {
|
||||
var resp Cloud189FilesResp
|
||||
@@ -195,7 +196,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
"mediaAttr": "0",
|
||||
"iconOption": "5",
|
||||
"pageNum": fmt.Sprint(pageNum),
|
||||
"pageSize": "130",
|
||||
"pageSize": fmt.Sprint(pageSize),
|
||||
})
|
||||
if isFamily {
|
||||
r.SetQueryParams(map[string]string{
|
||||
@@ -218,13 +219,22 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
if resp.FileListAO.Count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
FolderCount := len(resp.FileListAO.FolderList) // 当前文件夹总数
|
||||
FileCount := len(resp.FileListAO.FileList) // 当前文件总数
|
||||
PageCount := FolderCount + FileCount // 当前页数总数
|
||||
|
||||
for i := 0; i < len(resp.FileListAO.FolderList); i++ {
|
||||
for i := 0; i < FolderCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FolderList[i])
|
||||
}
|
||||
for i := 0; i < len(resp.FileListAO.FileList); i++ {
|
||||
for i := 0; i < FileCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FileList[i])
|
||||
}
|
||||
|
||||
// 文件数量小于设定数量时跳出
|
||||
if PageCount < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
+31
-2
@@ -262,6 +262,16 @@ func (y *Cloud189PC) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.
|
||||
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 跟随移动 torrent 文件
|
||||
if !srcObj.IsDir() {
|
||||
var srcFolderId string
|
||||
if f, ok := srcObj.(*Cloud189File); ok {
|
||||
srcFolderId = f.ParentID
|
||||
}
|
||||
y.torrentFollowMove(srcFolderId, srcObj.GetName(), dstDir)
|
||||
}
|
||||
|
||||
return srcObj, nil
|
||||
}
|
||||
|
||||
@@ -291,13 +301,19 @@ func (y *Cloud189PC) Rename(ctx context.Context, srcObj model.Obj, newName strin
|
||||
var resp RenameResp
|
||||
_, err := y.request(fullUrl, method, func(req *resty.Request) {
|
||||
req.SetContext(ctx).SetQueryParams(queryParam)
|
||||
}, nil, resp, isFamily)
|
||||
}, nil, &resp, isFamily)
|
||||
if err != nil {
|
||||
if code, ok := resp.ResCode.(string); ok && code == "FileAlreadyExists" {
|
||||
return nil, errs.ObjectAlreadyExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 跟随重命名 torrent 文件
|
||||
if f, ok := srcObj.(*Cloud189File); ok {
|
||||
y.torrentFollowRename(f.ParentID, srcObj.GetName(), newName)
|
||||
}
|
||||
|
||||
switch f := srcObj.(type) {
|
||||
case *Cloud189File:
|
||||
return resp.toFile(f), nil
|
||||
@@ -319,7 +335,20 @@ func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return y.WaitBatchTask("COPY", resp.TaskID, time.Second)
|
||||
if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跟随复制 torrent 文件
|
||||
if !srcObj.IsDir() {
|
||||
var srcFolderId string
|
||||
if f, ok := srcObj.(*Cloud189File); ok {
|
||||
srcFolderId = f.ParentID
|
||||
}
|
||||
y.torrentFollowCopy(srcFolderId, srcObj.GetName(), dstDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) Remove(ctx context.Context, obj model.Obj) error {
|
||||
|
||||
+20
-3
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
|
||||
)
|
||||
|
||||
@@ -101,7 +103,13 @@ func MustParseTime(str string) *time.Time {
|
||||
|
||||
type Time time.Time
|
||||
|
||||
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
|
||||
func (t *Time) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Unmarshal([]byte(s))
|
||||
}
|
||||
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
b, err := e.Token()
|
||||
if err != nil {
|
||||
@@ -116,10 +124,19 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
|
||||
}
|
||||
func (t *Time) Unmarshal(b []byte) error {
|
||||
bs := strings.Trim(string(b), "\"")
|
||||
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
|
||||
bs = strings.ReplaceAll(bs, "\u202f", " ")
|
||||
bs = strings.ReplaceAll(bs, "\u00a0", " ")
|
||||
var v time.Time
|
||||
var err error
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
|
||||
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
|
||||
for _, s := range []string{bs, bs + " +08"} {
|
||||
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
|
||||
v, err = time.ParseInLocation(f, s, utils.CNLoc)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package _189pc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeUnmarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Time
|
||||
}{
|
||||
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
|
||||
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
|
||||
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
|
||||
}
|
||||
if !tt.want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
|
||||
t.Fatal("Unmarshal accepted an invalid time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
|
||||
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
var tm Time
|
||||
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
|
||||
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalXML(t *testing.T) {
|
||||
var tm Time
|
||||
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33 PM +08</time>`), &tm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
|
||||
if !want.Equal(time.Time(tm)) {
|
||||
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
|
||||
}
|
||||
}
|
||||
+158
-5
@@ -1,6 +1,7 @@
|
||||
package _189pc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
@@ -8,10 +9,13 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
|
||||
"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/torrent"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
@@ -89,7 +93,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
|
||||
sliceMd5Hex = strings.ToUpper(utils.GetMD5EncodeStr(strings.Join(upperSliceMD5s, "\n")))
|
||||
}
|
||||
|
||||
|
||||
// 使用与 Web 端一致的三步秒传流程
|
||||
fullUrl := "https://upload.cloud.189.cn"
|
||||
if isFamily {
|
||||
@@ -110,7 +113,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
|
||||
initParams.Set("familyId", y.FamilyID)
|
||||
}
|
||||
|
||||
|
||||
var uploadInfo InitMultiUploadResp
|
||||
_, err = y.request(fullUrl+"/initMultiUpload", "GET", func(req *resty.Request) {
|
||||
req.SetContext(ctx)
|
||||
@@ -119,7 +121,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
|
||||
return nil, fmt.Errorf("initMultiUpload 失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
uploadFileId := uploadInfo.Data.UploadFileID
|
||||
|
||||
// Step 2: checkTransSecond(用 fileMd5 + sliceMd5 + uploadFileId 检查秒传)
|
||||
@@ -129,7 +130,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
|
||||
"uploadFileId": uploadFileId,
|
||||
}
|
||||
|
||||
|
||||
var checkResp struct {
|
||||
Data struct {
|
||||
FileDataExists int `json:"fileDataExists"`
|
||||
@@ -143,7 +143,6 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
|
||||
return nil, fmt.Errorf("秒传检查失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
if checkResp.Data.FileDataExists != 1 {
|
||||
return nil, fmt.Errorf("秒传失败:云端不存在该文件(fileMD5=%s, sliceMD5=%s, size=%d)", fileMD5Upper, sliceMd5Hex, fileSize)
|
||||
}
|
||||
@@ -167,6 +166,36 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
|
||||
return nil, fmt.Errorf("提交上传失败: %w", err)
|
||||
}
|
||||
|
||||
// 秒传成功后,将 torrent 文件上传到目标目录(异步,不影响秒传结果)
|
||||
if y.Addition.GenerateTorrent {
|
||||
capturedDstDir := dstDir
|
||||
capturedIsFamily := isFamily
|
||||
go func() {
|
||||
torrentName := fileName + ".cas.torrent"
|
||||
infoHash, _ := GetInfoHashHex(torrentData)
|
||||
utils.Log.Infof("秒传成功,上传 torrent: %s (info_hash: %s, size: %d bytes)",
|
||||
torrentName, infoHash, len(torrentData))
|
||||
|
||||
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, 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
|
||||
}
|
||||
|
||||
@@ -263,6 +292,10 @@ func GetInfoHashHex(torrentData []byte) (string, error) {
|
||||
return hex.EncodeToString(t.InfoHash), nil
|
||||
}
|
||||
|
||||
func isCASTorrentFile(fileName string) bool {
|
||||
return strings.HasSuffix(fileName, ".cas.torrent")
|
||||
}
|
||||
|
||||
// ComputeSliceMD5sFromReader 从 reader 中计算每个 10MB 分片的 MD5
|
||||
// 返回:整文件 MD5、分片 MD5 列表
|
||||
func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []string, error) {
|
||||
@@ -294,3 +327,123 @@ func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []st
|
||||
fileMD5Hex := strings.ToUpper(hex.EncodeToString(fileMD5Hash.Sum(nil)))
|
||||
return fileMD5Hex, sliceMD5s, nil
|
||||
}
|
||||
|
||||
// torrentFollowCopy 跟随复制 torrent 文件(异步,不影响主操作)
|
||||
// srcFolderId: 源文件所在目录 ID
|
||||
// srcFileName: 源文件名
|
||||
// dstDir: 目标目录
|
||||
func (y *Cloud189PC) torrentFollowCopy(srcFolderId string, srcFileName string, dstDir model.Obj) {
|
||||
if !y.Addition.GenerateTorrent {
|
||||
return
|
||||
}
|
||||
if srcFolderId == "" {
|
||||
return
|
||||
}
|
||||
torrentName := srcFileName + ".cas.torrent"
|
||||
isFamily := y.isFamily()
|
||||
|
||||
go func() {
|
||||
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
|
||||
if err != nil {
|
||||
utils.Log.Debugf("查找 torrent 文件失败(跟随复制): %v", err)
|
||||
return
|
||||
}
|
||||
// 复制 torrent 文件到目标目录
|
||||
resp, copyErr := y.CreateBatchTask("COPY", IF(isFamily, y.FamilyID, ""), dstDir.GetID(),
|
||||
map[string]string{"targetFileName": dstDir.GetName()},
|
||||
BatchTaskInfo{
|
||||
FileId: torrentFile.GetID(),
|
||||
FileName: torrentFile.GetName(),
|
||||
IsFolder: 0,
|
||||
})
|
||||
if copyErr != nil {
|
||||
utils.Log.Warnf("跟随复制 torrent 文件失败: %v", copyErr)
|
||||
return
|
||||
}
|
||||
if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil {
|
||||
utils.Log.Warnf("等待跟随复制 torrent 文件失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// torrentFollowMove 跟随移动 torrent 文件(异步,不影响主操作)
|
||||
// srcFolderId: 源文件所在目录 ID
|
||||
// srcFileName: 源文件名
|
||||
// dstDir: 目标目录
|
||||
func (y *Cloud189PC) torrentFollowMove(srcFolderId string, srcFileName string, dstDir model.Obj) {
|
||||
if !y.Addition.GenerateTorrent {
|
||||
return
|
||||
}
|
||||
if srcFolderId == "" {
|
||||
return
|
||||
}
|
||||
torrentName := srcFileName + ".cas.torrent"
|
||||
isFamily := y.isFamily()
|
||||
|
||||
go func() {
|
||||
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
|
||||
if err != nil {
|
||||
utils.Log.Debugf("查找 torrent 文件失败(跟随移动): %v", err)
|
||||
return
|
||||
}
|
||||
// 移动 torrent 文件到目标目录
|
||||
resp, moveErr := y.CreateBatchTask("MOVE", IF(isFamily, y.FamilyID, ""), dstDir.GetID(),
|
||||
map[string]string{"targetFileName": dstDir.GetName()},
|
||||
BatchTaskInfo{
|
||||
FileId: torrentFile.GetID(),
|
||||
FileName: torrentFile.GetName(),
|
||||
IsFolder: 0,
|
||||
})
|
||||
if moveErr != nil {
|
||||
utils.Log.Warnf("跟随移动 torrent 文件失败: %v", moveErr)
|
||||
return
|
||||
}
|
||||
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
|
||||
utils.Log.Warnf("等待跟随移动 torrent 文件失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// torrentFollowRename 跟随重命名 torrent 文件(异步,不影响主操作)
|
||||
// folderId: 文件所在目录 ID
|
||||
// oldFileName: 原文件名
|
||||
// newFileName: 新文件名
|
||||
func (y *Cloud189PC) torrentFollowRename(folderId string, oldFileName string, newFileName string) {
|
||||
if !y.Addition.GenerateTorrent {
|
||||
return
|
||||
}
|
||||
if folderId == "" {
|
||||
return
|
||||
}
|
||||
oldTorrentName := oldFileName + ".cas.torrent"
|
||||
newTorrentName := newFileName + ".cas.torrent"
|
||||
isFamily := y.isFamily()
|
||||
|
||||
go func() {
|
||||
torrentFile, err := y.findFileByName(context.Background(), oldTorrentName, folderId, isFamily)
|
||||
if err != nil {
|
||||
utils.Log.Debugf("查找 torrent 文件失败(跟随重命名): %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 重命名 torrent 文件
|
||||
queryParam := make(map[string]string)
|
||||
fullUrl := API_URL
|
||||
method := "POST"
|
||||
if isFamily {
|
||||
fullUrl += "/family/file"
|
||||
method = "GET"
|
||||
queryParam["familyId"] = y.FamilyID
|
||||
}
|
||||
fullUrl += "/renameFile.action"
|
||||
queryParam["fileId"] = torrentFile.GetID()
|
||||
queryParam["destFileName"] = newTorrentName
|
||||
|
||||
_, renameErr := y.request(fullUrl, method, func(req *resty.Request) {
|
||||
req.SetContext(context.Background()).SetQueryParams(queryParam)
|
||||
}, nil, &RenameResp{}, isFamily)
|
||||
if renameErr != nil {
|
||||
utils.Log.Warnf("跟随重命名 torrent 文件失败: %v", renameErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
+16
-7
@@ -166,10 +166,11 @@ type FamilyInfoResp struct {
|
||||
/*文件部分*/
|
||||
// 文件
|
||||
type Cloud189File struct {
|
||||
ID String `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Md5 string `json:"md5"`
|
||||
ID String `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Md5 string `json:"md5"`
|
||||
ParentID string `json:"-"` // 由 getFiles 设置,不从 JSON 解析
|
||||
|
||||
LastOpTime Time `json:"lastOpTime"`
|
||||
CreateDate Time `json:"createDate"`
|
||||
@@ -190,6 +191,10 @@ type Cloud189File struct {
|
||||
// StarLabel int64 `json:"starLabel"`
|
||||
}
|
||||
|
||||
func normalizeCloud189Name(name string) string {
|
||||
return strings.ReplaceAll(name, "\\'", "'")
|
||||
}
|
||||
|
||||
func (c *Cloud189File) CreateTime() time.Time {
|
||||
return time.Time(c.CreateDate)
|
||||
}
|
||||
@@ -206,6 +211,8 @@ func (c *Cloud189File) GetID() string { return string(c.ID) }
|
||||
func (c *Cloud189File) GetPath() string { return "" }
|
||||
func (c *Cloud189File) Thumb() string { return c.Icon.SmallUrl }
|
||||
|
||||
func (c *Cloud189File) GetDisplayName() string { return normalizeCloud189Name(c.Name) }
|
||||
|
||||
// 文件夹
|
||||
type Cloud189Folder struct {
|
||||
ID String `json:"id"`
|
||||
@@ -237,6 +244,8 @@ func (c *Cloud189Folder) IsDir() bool { return true }
|
||||
func (c *Cloud189Folder) GetID() string { return string(c.ID) }
|
||||
func (c *Cloud189Folder) GetPath() string { return "" }
|
||||
|
||||
func (c *Cloud189Folder) GetDisplayName() string { return normalizeCloud189Name(c.Name) }
|
||||
|
||||
type Cloud189FilesResp struct {
|
||||
//ResCode int `json:"res_code"`
|
||||
//ResMessage string `json:"res_message"`
|
||||
@@ -432,7 +441,7 @@ type RenameResp struct {
|
||||
ResMsg string `json:"res_message"`
|
||||
CreateDate Time `json:"createDate"`
|
||||
FileCate int `json:"fileCata"`
|
||||
ID string `json:"id"`
|
||||
ID String `json:"id"`
|
||||
LastOpTime Time `json:"lastOpTime"`
|
||||
MD5 string `json:"md5"`
|
||||
MediaType int `json:"mediaType"`
|
||||
@@ -446,7 +455,7 @@ type RenameResp struct {
|
||||
|
||||
func (r *RenameResp) toFile(f *Cloud189File) *Cloud189File {
|
||||
return &Cloud189File{
|
||||
ID: String(r.ID),
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Size: r.Size,
|
||||
Md5: r.MD5,
|
||||
@@ -458,7 +467,7 @@ func (r *RenameResp) toFile(f *Cloud189File) *Cloud189File {
|
||||
|
||||
func (r *RenameResp) toFolder() *Cloud189Folder {
|
||||
return &Cloud189Folder{
|
||||
ID: String(r.ID),
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
ParentID: r.ParentID,
|
||||
LastOpTime: r.LastOpTime,
|
||||
|
||||
+76
-4
@@ -195,9 +195,10 @@ func (y *Cloud189PC) put(ctx context.Context, url string, headers map[string]str
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool) ([]model.Obj, error) {
|
||||
pageSize := 1000 // 每一页返回的文件数量
|
||||
res := make([]model.Obj, 0, 100)
|
||||
for pageNum := 1; ; pageNum++ {
|
||||
resp, err := y.getFilesWithPage(ctx, fileId, isFamily, pageNum, 1000, y.OrderBy, y.OrderDirection)
|
||||
resp, err := y.getFilesWithPage(ctx, fileId, isFamily, pageNum, pageSize, y.OrderBy, y.OrderDirection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -205,13 +206,23 @@ func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool)
|
||||
if resp.FileListAO.Count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
FolderCount := len(resp.FileListAO.FolderList) // 当前文件夹总数
|
||||
FileCount := len(resp.FileListAO.FileList) // 当前文件总数
|
||||
PageCount := FolderCount + FileCount // 当前页数总数
|
||||
|
||||
for i := 0; i < len(resp.FileListAO.FolderList); i++ {
|
||||
for i := 0; i < FolderCount; i++ {
|
||||
res = append(res, &resp.FileListAO.FolderList[i])
|
||||
}
|
||||
for i := 0; i < len(resp.FileListAO.FileList); i++ {
|
||||
for i := 0; i < FileCount; i++ {
|
||||
resp.FileListAO.FileList[i].ParentID = fileId
|
||||
res = append(res, &resp.FileListAO.FileList[i])
|
||||
}
|
||||
|
||||
// 当前文件数量小于设定数量则跳出
|
||||
if PageCount < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -910,6 +921,11 @@ func (y *Cloud189PC) RapidUpload(ctx context.Context, dstDir model.Obj, stream m
|
||||
|
||||
// 快传
|
||||
func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, error) {
|
||||
generateTorrent := y.Addition.GenerateTorrent && !isCASTorrentFile(file.GetName())
|
||||
return y.fastUpload(ctx, dstDir, file, up, isFamily, overwrite, generateTorrent)
|
||||
}
|
||||
|
||||
func (y *Cloud189PC) fastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool, generateTorrent bool) (model.Obj, error) {
|
||||
var (
|
||||
cache = file.GetFile()
|
||||
tmpF *os.File
|
||||
@@ -947,6 +963,9 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
||||
if tmpF != nil {
|
||||
writers = append(writers, tmpF)
|
||||
}
|
||||
|
||||
pieceSHA1Hashes := make([]byte, 0, count*20)
|
||||
|
||||
written := int64(0)
|
||||
for i := 1; i <= count; i++ {
|
||||
if utils.IsCanceled(ctx) {
|
||||
@@ -957,7 +976,17 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
||||
byteSize = lastSliceSize
|
||||
}
|
||||
|
||||
n, err := utils.CopyWithBufferN(io.MultiWriter(writers...), file, byteSize)
|
||||
// 如果需要生成 torrent,同时计算 SHA-1
|
||||
var sha1Writer hash.Hash
|
||||
var multiWriter io.Writer
|
||||
if generateTorrent {
|
||||
sha1Writer = sha1Pkg.New()
|
||||
multiWriter = io.MultiWriter(append(writers, sha1Writer)...)
|
||||
} else {
|
||||
multiWriter = io.MultiWriter(writers...)
|
||||
}
|
||||
|
||||
n, err := utils.CopyWithBufferN(multiWriter, file, byteSize)
|
||||
written += n
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
@@ -966,6 +995,11 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
||||
sliceMd5Hexs = append(sliceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Byte)))
|
||||
partInfos = append(partInfos, fmt.Sprint(i, "-", base64.StdEncoding.EncodeToString(md5Byte)))
|
||||
sliceMd5.Reset()
|
||||
|
||||
// 收集 SHA-1 piece hash(仅在本次分片实际写入了数据时追加)
|
||||
if generateTorrent && n > 0 {
|
||||
pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Writer.Sum(nil)...)
|
||||
}
|
||||
}
|
||||
|
||||
if tmpF != nil {
|
||||
@@ -1081,6 +1115,44 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成 torrent 文件(异步,不影响上传结果)
|
||||
if generateTorrent && size > 0 && len(pieceSHA1Hashes) > 0 {
|
||||
capturedDstDir := dstDir
|
||||
capturedIsFamily := isFamily
|
||||
capturedFileName := file.GetName()
|
||||
go func() {
|
||||
torrentData, err := GenerateTorrent(capturedFileName, size, fileMd5Hex, sliceMd5Hexs, 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 文件上传到同一目录
|
||||
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, 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
|
||||
}
|
||||
|
||||
|
||||
+25
-16
@@ -249,6 +249,7 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
}
|
||||
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 {
|
||||
@@ -258,9 +259,13 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
_ = link.Close()
|
||||
continue
|
||||
}
|
||||
l := *link // 复制一份,避免修改到原始link
|
||||
if l.ContentLength == 0 {
|
||||
l.ContentLength = fi.GetSize()
|
||||
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
|
||||
@@ -268,20 +273,27 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
if d.DownloadPartSize > 0 {
|
||||
l.PartSize = d.DownloadPartSize * utils.KB
|
||||
}
|
||||
rr, err := stream.GetRangeReaderFromLink(l.ContentLength, &l)
|
||||
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...),
|
||||
RangeReader: stream.RangeReaderFunc(rr),
|
||||
SyncClosers: utils.NewSyncClosers(linkClosers...),
|
||||
RequireReference: requireReference,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -315,14 +327,10 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resultLink := *link // 复制一份,避免修改到原始link
|
||||
resultLink := link.Clone() // 复制一份,避免修改到原始link
|
||||
resultLink.Expiration = nil
|
||||
resultLink.SyncClosers = utils.NewSyncClosers(link)
|
||||
if args.Redirect {
|
||||
return &resultLink, nil
|
||||
}
|
||||
if resultLink.ContentLength == 0 {
|
||||
resultLink.ContentLength = fi.GetSize()
|
||||
return resultLink, nil
|
||||
}
|
||||
if d.DownloadConcurrency > 0 {
|
||||
resultLink.Concurrency = d.DownloadConcurrency
|
||||
@@ -330,7 +338,10 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
if d.DownloadPartSize > 0 {
|
||||
resultLink.PartSize = d.DownloadPartSize * utils.KB
|
||||
}
|
||||
return &resultLink, nil
|
||||
if resultLink.ContentLength == 0 {
|
||||
resultLink.ContentLength = fi.GetSize()
|
||||
}
|
||||
return resultLink, nil
|
||||
}
|
||||
|
||||
func (d *Alias) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
|
||||
@@ -501,9 +512,7 @@ func (d *Alias) Extract(ctx context.Context, obj model.Obj, args model.ArchiveIn
|
||||
sign.SignArchive(reqPath)),
|
||||
}, nil
|
||||
}
|
||||
resultLink := *link
|
||||
resultLink.SyncClosers = utils.NewSyncClosers(link)
|
||||
return &resultLink, nil
|
||||
return link.Clone(), nil
|
||||
}
|
||||
|
||||
func (d *Alias) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) error {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
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{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func (d *AListV3) List(ctx context.Context, dir model.Obj, args model.ListArgs)
|
||||
file := model.ObjThumb{
|
||||
Object: model.Object{
|
||||
Name: f.Name,
|
||||
Path: path.Join(dir.GetPath(), f.Name),
|
||||
Modified: f.Modified,
|
||||
Ctime: f.Created,
|
||||
Size: f.Size,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package alist_v3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// TestListSetsChildPaths descends two levels the way op.Get does, feeding an
|
||||
// object from one listing back into List as dir. Without a Path on that object
|
||||
// the driver asks upstream for "", which a real server answers with its own
|
||||
// root -- hence the fake upstream's fallback, and the endless self-similar tree.
|
||||
func TestListSetsChildPaths(t *testing.T) {
|
||||
tree := map[string][]ObjResp{
|
||||
"/": {{Name: "root-marker", IsDir: true}},
|
||||
"/drive": {{Name: "concerts", IsDir: true}},
|
||||
"/drive/concerts": {{Name: "show.mkv"}},
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req ListReq
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
content, ok := tree[req.Path]
|
||||
if !ok {
|
||||
content = tree["/"]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json") // resty only unmarshals JSON
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 200, "message": "success",
|
||||
"data": map[string]any{"content": content, "total": len(content)},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// conf.Conf is nil outside a booted server, so base.InitClient() is unusable.
|
||||
prev := base.RestyClient
|
||||
base.RestyClient = resty.New().SetTimeout(5 * time.Second)
|
||||
t.Cleanup(func() { base.RestyClient = prev })
|
||||
|
||||
d := &AListV3{Addition: Addition{
|
||||
RootPath: driver.RootPath{RootFolderPath: "/drive"},
|
||||
Address: srv.URL,
|
||||
}}
|
||||
dir := model.Obj(&model.Object{Path: "/drive", IsFolder: true})
|
||||
for _, want := range []string{"/drive/concerts", "/drive/concerts/show.mkv"} {
|
||||
objs, err := d.List(context.Background(), dir, model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("List(%q): %v", dir.GetPath(), err)
|
||||
}
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("List(%q) returned %d objects, want 1", dir.GetPath(), len(objs))
|
||||
}
|
||||
if got := objs[0].GetPath(); got != want {
|
||||
t.Fatalf("child of %q has path %q, want %q", dir.GetPath(), got, want)
|
||||
}
|
||||
dir = objs[0]
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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"
|
||||
@@ -21,8 +22,10 @@ import (
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/azure_blob"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_netdisk"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/bunny_storage"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/chunk"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve_v4"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/cnb_releases"
|
||||
@@ -32,12 +35,14 @@ import (
|
||||
_ "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"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/github_releases"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_drive"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_photo"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud_open"
|
||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/ilanzou"
|
||||
|
||||
@@ -2,6 +2,7 @@ package azure_blob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
@@ -12,10 +13,13 @@ import (
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Azure Blob Storage based on the blob APIs
|
||||
@@ -283,6 +287,26 @@ func (d *AzureBlob) Put(ctx context.Context, dstDir model.Obj, stream model.File
|
||||
// Determine optimal upload options based on file size
|
||||
options := optimizedUploadOptions(stream.GetSize())
|
||||
|
||||
// Preserve the file content type for direct Azure Blob downloads.
|
||||
contentType := stream.GetMimetype()
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
options.HTTPHeaders = &blob.HTTPHeaders{
|
||||
BlobContentType: &contentType,
|
||||
}
|
||||
|
||||
// Preserve the MD5 checksum when it is available.
|
||||
md5Hex := stream.GetHash().GetHash(utils.MD5)
|
||||
if md5Hex != "" {
|
||||
md5, err := hex.DecodeString(md5Hex)
|
||||
if err == nil && len(md5) == 16 {
|
||||
options.HTTPHeaders.BlobContentMD5 = md5
|
||||
} else {
|
||||
log.Warnf("Invalid MD5 hash: %s, error: %v", md5Hex, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Track upload progress
|
||||
progressTracker := &progressTracker{
|
||||
total: stream.GetSize(),
|
||||
|
||||
@@ -215,11 +215,13 @@ func (d *BaiduNetdisk) linkOfficial(file model.Obj, _ model.LinkArgs) (*model.Li
|
||||
u = res.Header().Get("location")
|
||||
//}
|
||||
|
||||
exp := time.Hour
|
||||
return &model.Link{
|
||||
URL: u,
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"pan.baidu.com"},
|
||||
},
|
||||
Expiration: &exp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ func (c *File) Thumb() string {
|
||||
}
|
||||
|
||||
func (c *File) GetHash() utils.HashInfo {
|
||||
return utils.NewHashInfo(utils.MD5, DecryptMd5(c.Md5))
|
||||
return utils.HashInfo{}
|
||||
}
|
||||
|
||||
/*相册部分*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
@@ -411,12 +412,14 @@ func (d *BaiduPhoto) linkFile(ctx context.Context, file *File, args model.LinkAr
|
||||
// }
|
||||
|
||||
// location := resp.Header().Get("Location")
|
||||
exp := time.Hour
|
||||
link := &model.Link{
|
||||
URL: downloadUrl.Dlink,
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{headers["User-Agent"]},
|
||||
"Referer": []string{"https://photo.baidu.com/"},
|
||||
},
|
||||
Expiration: &exp,
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package bunny_storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
stdpath "path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type BunnyStorage struct {
|
||||
model.Storage
|
||||
Addition
|
||||
client *resty.Client
|
||||
endpoint *url.URL
|
||||
cdnBase *url.URL
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) Config() driver.Config {
|
||||
cfg := config
|
||||
if d.StorageZoneName != "" && d.CDNBaseURL == "" {
|
||||
cfg.OnlyProxy = true
|
||||
cfg.PreferProxy = true
|
||||
}
|
||||
if d.CDNTokenKey != "" && d.CDNTokenIncludeIP {
|
||||
cfg.LinkCacheMode = driver.LinkCacheIP
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) GetAddition() driver.Additional {
|
||||
return &d.Addition
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) Init(ctx context.Context) error {
|
||||
if d.RootFolderPath == "" {
|
||||
d.RootFolderPath = "/"
|
||||
}
|
||||
if d.Endpoint == "" {
|
||||
d.Endpoint = defaultEndpoint
|
||||
}
|
||||
if d.SignURLExpire <= 0 {
|
||||
d.SignURLExpire = 4
|
||||
}
|
||||
if d.CDNTokenMethod == "" {
|
||||
d.CDNTokenMethod = cdnTokenMethodSHA256
|
||||
}
|
||||
endpoint, err := normalizeBaseURL(d.Endpoint, defaultEndpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid endpoint: %w", err)
|
||||
}
|
||||
d.endpoint = endpoint
|
||||
if d.CDNBaseURL != "" {
|
||||
cdnBase, err := normalizeBaseURL(d.CDNBaseURL, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cdn_base_url: %w", err)
|
||||
}
|
||||
d.cdnBase = cdnBase
|
||||
}
|
||||
d.client = base.RestyClient
|
||||
if d.client == nil {
|
||||
d.client = base.NewRestyClient()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) Drop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
var items []bunnyObject
|
||||
resp, err := d.authRequest().
|
||||
SetContext(ctx).
|
||||
SetResult(&items).
|
||||
Get(d.storageURL(dir.GetPath(), true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.handleResponseError(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]model.Obj, 0, len(items))
|
||||
placeholder := d.placeholderName()
|
||||
for _, item := range items {
|
||||
if item.ObjectName == "" {
|
||||
continue
|
||||
}
|
||||
if !args.S3ShowPlaceholder && !item.IsDirectory && item.ObjectName == placeholder {
|
||||
continue
|
||||
}
|
||||
result = append(result, d.toObj(dir.GetPath(), item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if file.IsDir() {
|
||||
return nil, errs.NotFile
|
||||
}
|
||||
cacheTTL := time.Duration(0)
|
||||
if d.cdnBase != nil {
|
||||
linkURL := d.cdnURL(d.cdnObjectPath(file.GetPath()))
|
||||
link := &model.Link{
|
||||
URL: linkURL,
|
||||
ContentLength: file.GetSize(),
|
||||
Expiration: &cacheTTL,
|
||||
}
|
||||
if d.CDNTokenKey != "" {
|
||||
signedURL, _, err := d.signCDNURL(linkURL, args.IP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link.URL = signedURL
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
return &model.Link{
|
||||
URL: d.storageURL(file.GetPath(), false),
|
||||
Header: http.Header{"AccessKey": []string{d.AccessKey}},
|
||||
ContentLength: file.GetSize(),
|
||||
Expiration: &cacheTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
|
||||
dirPath := stdpath.Join(parentDir.GetPath(), dirName)
|
||||
placeholderPath := stdpath.Join(dirPath, d.placeholderName())
|
||||
if err := d.putReader(ctx, placeholderPath, bytes.NewReader(nil), 0, "application/octet-stream", nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
return &model.Object{
|
||||
Path: dirPath,
|
||||
Name: dirName,
|
||||
Modified: now,
|
||||
Ctime: now,
|
||||
IsFolder: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) Remove(ctx context.Context, obj model.Obj) error {
|
||||
resp, err := d.authRequest().
|
||||
SetContext(ctx).
|
||||
Delete(d.storageURL(obj.GetPath(), obj.IsDir()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.handleResponseError(resp)
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
||||
if up == nil {
|
||||
up = func(float64) {}
|
||||
}
|
||||
dstPath := stdpath.Join(dstDir.GetPath(), file.GetName())
|
||||
err := d.putReader(ctx, dstPath, driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
|
||||
Reader: file,
|
||||
UpdateProgress: up,
|
||||
}), file.GetSize(), file.GetMimetype(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
return &model.Object{
|
||||
Path: dstPath,
|
||||
Name: file.GetName(),
|
||||
Size: file.GetSize(),
|
||||
Modified: now,
|
||||
Ctime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) putReader(ctx context.Context, path string, body any, size int64, contentType string, extraHeaders http.Header) error {
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
req := d.authRequest().
|
||||
SetContext(ctx).
|
||||
SetBody(body).
|
||||
SetHeader("Content-Type", contentType)
|
||||
if size >= 0 {
|
||||
req.SetHeader("Content-Length", fmt.Sprint(size))
|
||||
}
|
||||
for key, values := range extraHeaders {
|
||||
for _, value := range values {
|
||||
req.SetHeader(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := req.Put(d.storageURL(path, false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.handleResponseError(resp)
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
fullPath := stdpath.Join(d.GetRootPath(), path)
|
||||
parentPath, name := stdpath.Split(fullPath)
|
||||
parentPath = strings.TrimSuffix(parentPath, "/")
|
||||
if parentPath == "" {
|
||||
parentPath = "/"
|
||||
}
|
||||
objs, err := d.List(ctx, &model.Object{Path: parentPath, IsFolder: true}, model.ListArgs{S3ShowPlaceholder: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, obj := range objs {
|
||||
if obj.GetName() == name {
|
||||
return obj, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.ObjectNotFound
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*BunnyStorage)(nil)
|
||||
var _ driver.Getter = (*BunnyStorage)(nil)
|
||||
@@ -0,0 +1,32 @@
|
||||
package bunny_storage
|
||||
|
||||
import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
driver.RootPath
|
||||
StorageZoneName string `json:"storage_zone_name" required:"true"`
|
||||
AccessKey string `json:"access_key" required:"true"`
|
||||
Endpoint string `json:"endpoint" required:"true" default:"storage.bunnycdn.com"`
|
||||
CDNBaseURL string `json:"cdn_base_url"`
|
||||
CDNTokenKey string `json:"cdn_token_key"`
|
||||
CDNTokenMethod string `json:"cdn_token_method" type:"select" options:"sha256,hmac_sha256" default:"sha256"`
|
||||
CDNTokenIncludeIP bool `json:"cdn_token_include_ip" default:"false"`
|
||||
SignURLExpire int `json:"sign_url_expire" type:"number" default:"4"`
|
||||
Placeholder string `json:"placeholder" default:".openlist"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "Bunny Storage",
|
||||
LocalSort: true,
|
||||
DefaultRoot: "/",
|
||||
CheckStatus: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
op.RegisterDriver(func() driver.Driver {
|
||||
return &BunnyStorage{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package bunny_storage
|
||||
|
||||
import "time"
|
||||
|
||||
type bunnyObject struct {
|
||||
Guid string `json:"Guid"`
|
||||
StorageZoneName string `json:"StorageZoneName"`
|
||||
Path string `json:"Path"`
|
||||
ObjectName string `json:"ObjectName"`
|
||||
Length int64 `json:"Length"`
|
||||
LastChanged string `json:"LastChanged"`
|
||||
IsDirectory bool `json:"IsDirectory"`
|
||||
ServerID int `json:"ServerId"`
|
||||
UserID string `json:"UserId"`
|
||||
DateCreated string `json:"DateCreated"`
|
||||
StorageZoneID int64 `json:"StorageZoneId"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
HttpCode int `json:"HttpCode"`
|
||||
Message string `json:"Message"`
|
||||
}
|
||||
|
||||
type parsedTimes struct {
|
||||
modified time.Time
|
||||
created time.Time
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package bunny_storage
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
stdpath "path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEndpoint = "storage.bunnycdn.com"
|
||||
defaultPlaceholder = ".openlist"
|
||||
|
||||
cdnTokenMethodSHA256 = "sha256"
|
||||
cdnTokenMethodHMACSHA256 = "hmac_sha256"
|
||||
)
|
||||
|
||||
func normalizeBaseURL(raw string, fallback string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
raw = fallback
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("empty url")
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid url: %s", raw)
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func cleanObjectPath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return stdpath.Clean("/" + strings.TrimPrefix(path, "/"))
|
||||
}
|
||||
|
||||
func stripObjectPathPrefix(path string, prefix string) (string, bool) {
|
||||
path = cleanObjectPath(path)
|
||||
prefix = cleanObjectPath(prefix)
|
||||
if prefix == "/" {
|
||||
return path, false
|
||||
}
|
||||
if path == prefix {
|
||||
return "/", true
|
||||
}
|
||||
if strings.HasPrefix(path, prefix+"/") {
|
||||
return cleanObjectPath(strings.TrimPrefix(path, prefix)), true
|
||||
}
|
||||
return path, false
|
||||
}
|
||||
|
||||
func isObjectPathOrChild(path string, parent string) bool {
|
||||
path = cleanObjectPath(path)
|
||||
parent = cleanObjectPath(parent)
|
||||
return path == parent || strings.HasPrefix(path, parent+"/")
|
||||
}
|
||||
|
||||
func trimCDNBasePath(path string, mountPath string) string {
|
||||
path = cleanObjectPath(path)
|
||||
if path == "/" {
|
||||
return ""
|
||||
}
|
||||
if stripped, ok := stripObjectPathPrefix(path, mountPath); ok {
|
||||
path = stripped
|
||||
}
|
||||
if path == "/" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(path, "/")
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) cdnObjectPath(path string) string {
|
||||
objectPath := cleanObjectPath(path)
|
||||
if stripped, ok := stripObjectPathPrefix(objectPath, d.GetStorage().MountPath); ok {
|
||||
objectPath = stripped
|
||||
}
|
||||
rootPath := cleanObjectPath(d.GetRootPath())
|
||||
if rootPath != "/" && !isObjectPathOrChild(objectPath, rootPath) {
|
||||
objectPath = cleanObjectPath(stdpath.Join(rootPath, objectPath))
|
||||
}
|
||||
return objectPath
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) placeholderName() string {
|
||||
if d.Placeholder == "" {
|
||||
return defaultPlaceholder
|
||||
}
|
||||
return d.Placeholder
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) storageURL(path string, dir bool) string {
|
||||
u := *d.endpoint
|
||||
cleanPath := cleanObjectPath(path)
|
||||
zone := strings.Trim(d.StorageZoneName, "/")
|
||||
if cleanPath == "/" {
|
||||
u.Path = "/" + zone + "/"
|
||||
return u.String()
|
||||
}
|
||||
u.Path = "/" + zone + "/" + strings.TrimPrefix(cleanPath, "/")
|
||||
if dir && !strings.HasSuffix(u.Path, "/") {
|
||||
u.Path += "/"
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) cdnURL(path string) string {
|
||||
u := *d.cdnBase
|
||||
cleanPath := cleanObjectPath(path)
|
||||
basePath := trimCDNBasePath(u.Path, d.GetStorage().MountPath)
|
||||
if cleanPath == "/" {
|
||||
if basePath == "" {
|
||||
u.Path = "/"
|
||||
} else {
|
||||
u.Path = basePath + "/"
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
u.Path = basePath + "/" + strings.TrimPrefix(cleanPath, "/")
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) authRequest() *resty.Request {
|
||||
return d.client.R().SetHeader("AccessKey", d.AccessKey)
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) handleResponseError(resp *resty.Response) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("empty response")
|
||||
}
|
||||
if resp.StatusCode() >= http.StatusOK && resp.StatusCode() < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
message := strings.TrimSpace(resp.String())
|
||||
var apiErrors []apiError
|
||||
if err := json.Unmarshal(resp.Body(), &apiErrors); err == nil && len(apiErrors) > 0 && apiErrors[0].Message != "" {
|
||||
message = apiErrors[0].Message
|
||||
}
|
||||
switch resp.StatusCode() {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return errs.NewErr(errs.PermissionDenied, "bunny storage request failed: %s", message)
|
||||
case http.StatusNotFound:
|
||||
return errs.NewErr(errs.ObjectNotFound, "bunny storage request failed: %s", message)
|
||||
default:
|
||||
return fmt.Errorf("bunny storage request failed: %s: %s", resp.Status(), message)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) parseTimes(item bunnyObject) parsedTimes {
|
||||
return parsedTimes{
|
||||
modified: parseBunnyTime(item.LastChanged, d.Modified),
|
||||
created: parseBunnyTime(item.DateCreated, time.Time{}),
|
||||
}
|
||||
}
|
||||
|
||||
func parseBunnyTime(value string, fallback time.Time) time.Time {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02T15:04:05.999999999", value); err == nil {
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil {
|
||||
return t
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) toObj(parentPath string, item bunnyObject) model.Obj {
|
||||
times := d.parseTimes(item)
|
||||
return &model.Object{
|
||||
ID: item.Guid,
|
||||
Path: stdpath.Join(parentPath, item.ObjectName),
|
||||
Name: item.ObjectName,
|
||||
Size: item.Length,
|
||||
Modified: times.modified,
|
||||
Ctime: times.created,
|
||||
IsFolder: item.IsDirectory,
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalQuery(values url.Values) (string, error) {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
if key == "token" || key == "expires" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
vals := values[key]
|
||||
if len(vals) > 1 {
|
||||
return "", fmt.Errorf("duplicate query parameter %q is not supported", key)
|
||||
}
|
||||
value := ""
|
||||
if len(vals) == 1 {
|
||||
value = vals[0]
|
||||
}
|
||||
parts = append(parts, key+"="+value)
|
||||
}
|
||||
return strings.Join(parts, "&"), nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) signCDNURL(rawURL string, clientIP string) (string, time.Duration, error) {
|
||||
return d.signCDNURLAt(rawURL, clientIP, time.Now())
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) signCDNURLAt(rawURL string, clientIP string, now time.Time) (string, time.Duration, error) {
|
||||
expire := time.Hour * time.Duration(d.SignURLExpire)
|
||||
if expire <= 0 {
|
||||
expire = 4 * time.Hour
|
||||
}
|
||||
expires := now.Add(expire).Unix()
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
query := u.Query()
|
||||
parameterData, err := canonicalQuery(query)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
signaturePath, err := url.PathUnescape(u.EscapedPath())
|
||||
if err != nil {
|
||||
signaturePath = u.Path
|
||||
}
|
||||
if !d.CDNTokenIncludeIP {
|
||||
clientIP = ""
|
||||
}
|
||||
token := d.signCDNToken(signaturePath, strconv.FormatInt(expires, 10), parameterData, clientIP)
|
||||
query.Set("token", token)
|
||||
query.Set("expires", strconv.FormatInt(expires, 10))
|
||||
u.RawQuery = query.Encode()
|
||||
return u.String(), expire, nil
|
||||
}
|
||||
|
||||
func (d *BunnyStorage) signCDNToken(signaturePath string, expires string, parameterData string, clientIP string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(d.CDNTokenMethod)) {
|
||||
case cdnTokenMethodHMACSHA256:
|
||||
message := signaturePath + expires + parameterData + clientIP
|
||||
mac := hmac.New(sha256.New, []byte(d.CDNTokenKey))
|
||||
_, _ = mac.Write([]byte(message))
|
||||
return "HS256-" + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
default:
|
||||
hashableBase := d.CDNTokenKey + signaturePath + expires + parameterData + clientIP
|
||||
sum := sha256.Sum256([]byte(hashableBase))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package bunny_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
)
|
||||
|
||||
func TestStorageURL(t *testing.T) {
|
||||
endpoint, err := normalizeBaseURL("ny.storage.bunnycdn.com", defaultEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver := &BunnyStorage{
|
||||
Addition: Addition{
|
||||
StorageZoneName: "my-zone",
|
||||
},
|
||||
endpoint: endpoint,
|
||||
}
|
||||
if got, want := driver.storageURL("/", true), "https://ny.storage.bunnycdn.com/my-zone/"; got != want {
|
||||
t.Fatalf("root list url = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := driver.storageURL("/dir/a file.txt", false), "https://ny.storage.bunnycdn.com/my-zone/dir/a%20file.txt"; got != want {
|
||||
t.Fatalf("file url = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := driver.storageURL("/dir", true), "https://ny.storage.bunnycdn.com/my-zone/dir/"; got != want {
|
||||
t.Fatalf("dir url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDNURLWithBasePath(t *testing.T) {
|
||||
cdnBase, err := normalizeBaseURL("https://cdn.example.com/prefix/", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver := &BunnyStorage{cdnBase: cdnBase}
|
||||
if got, want := driver.cdnURL("/dir/a file.txt"), "https://cdn.example.com/prefix/dir/a%20file.txt"; got != want {
|
||||
t.Fatalf("cdn url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDNURLUsesObjectPathWithoutMountPath(t *testing.T) {
|
||||
cdnBase, err := normalizeBaseURL("https://cdn.firmant.me", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver := &BunnyStorage{
|
||||
Storage: model.Storage{MountPath: "/BS"},
|
||||
cdnBase: cdnBase,
|
||||
}
|
||||
if got, want := driver.cdnURL(driver.cdnObjectPath("/BS/Video")), "https://cdn.firmant.me/Video"; got != want {
|
||||
t.Fatalf("cdn url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDNURLDropsMountPathFromBaseURL(t *testing.T) {
|
||||
cdnBase, err := normalizeBaseURL("https://cdn.firmant.me/BS/", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver := &BunnyStorage{
|
||||
Storage: model.Storage{MountPath: "/BS"},
|
||||
cdnBase: cdnBase,
|
||||
}
|
||||
if got, want := driver.cdnURL(driver.cdnObjectPath("/Video")), "https://cdn.firmant.me/Video"; got != want {
|
||||
t.Fatalf("cdn url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDNObjectPathKeepsRootFolderPath(t *testing.T) {
|
||||
driver := &BunnyStorage{}
|
||||
driver.MountPath = "/BS"
|
||||
driver.RootFolderPath = "/library"
|
||||
if got, want := driver.cdnObjectPath("/BS/Video"), "/library/Video"; got != want {
|
||||
t.Fatalf("cdn object path = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := driver.cdnObjectPath("/library/Video"), "/library/Video"; got != want {
|
||||
t.Fatalf("cdn object path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkDisablesLongLivedCache(t *testing.T) {
|
||||
cdnBase, err := normalizeBaseURL("https://cdn.example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver := &BunnyStorage{cdnBase: cdnBase}
|
||||
link, err := driver.Link(context.Background(), &model.Object{
|
||||
Path: "/video.mp4",
|
||||
Name: "video.mp4",
|
||||
Size: 123,
|
||||
}, model.LinkArgs{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link.Expiration == nil || *link.Expiration != 0 {
|
||||
t.Fatalf("link expiration = %v, want immediate cache expiration", link.Expiration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignCDNURL(t *testing.T) {
|
||||
driver := &BunnyStorage{
|
||||
Addition: Addition{
|
||||
CDNTokenKey: "secret",
|
||||
CDNTokenIncludeIP: true,
|
||||
SignURLExpire: 1,
|
||||
},
|
||||
}
|
||||
signed, expire, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if expire <= 0 {
|
||||
t.Fatal("expected positive expiration")
|
||||
}
|
||||
parsed, err := url.Parse(signed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := parsed.Query().Get("token")
|
||||
if want := "FxSpFem88zFo6uHFziwTuoMQTgDaD2PEn5n1zTMBUBI"; token != want {
|
||||
t.Fatalf("token = %q, want %q", token, want)
|
||||
}
|
||||
if parsed.Query().Get("expires") != "1700003600" {
|
||||
t.Fatalf("expires = %q, want 1700003600", parsed.Query().Get("expires"))
|
||||
}
|
||||
if parsed.Query().Get("quality") != "high" {
|
||||
t.Fatal("expected existing query parameters to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignCDNURLSupportsHMACSHA256(t *testing.T) {
|
||||
driver := &BunnyStorage{
|
||||
Addition: Addition{
|
||||
CDNTokenKey: "secret",
|
||||
CDNTokenMethod: cdnTokenMethodHMACSHA256,
|
||||
CDNTokenIncludeIP: true,
|
||||
SignURLExpire: 1,
|
||||
},
|
||||
}
|
||||
signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(signed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := parsed.Query().Get("token"), "HS256-sdrSSJE2JVwhSk2AoDUrmTV1muH6R5UHpZVcVfHeNxg"; got != want {
|
||||
t.Fatalf("token = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignCDNURLUsesDecodedPathForSHA256(t *testing.T) {
|
||||
driver := &BunnyStorage{
|
||||
Addition: Addition{
|
||||
CDNTokenKey: "secret",
|
||||
SignURLExpire: 1,
|
||||
},
|
||||
}
|
||||
signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/%E8%A7%86%E9%A2%91/%5Ba%20b%5D.mp4", "", time.Unix(1700000000, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(signed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := parsed.Query().Get("token"), "yq1evD7klw0e3DjCbv8dJptbW4S4JwVW3GKLnxfeKGM"; got != want {
|
||||
t.Fatalf("token = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignCDNURLTreatsPlusAsLiteralPathCharacter(t *testing.T) {
|
||||
driver := &BunnyStorage{
|
||||
Addition: Addition{
|
||||
CDNTokenKey: "secret",
|
||||
SignURLExpire: 1,
|
||||
},
|
||||
}
|
||||
now := time.Unix(1700000000, 0)
|
||||
literal, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a+b.mp4", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a%2Bb.mp4", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
space, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a%20b.mp4", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
literalURL, err := url.Parse(literal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedURL, err := url.Parse(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spaceURL, err := url.Parse(space)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := literalURL.Query().Get("token"), encodedURL.Query().Get("token"); got != want {
|
||||
t.Fatalf("literal plus token = %q, encoded plus token = %q", got, want)
|
||||
}
|
||||
if got, notWant := literalURL.Query().Get("token"), spaceURL.Query().Get("token"); got == notWant {
|
||||
t.Fatalf("literal plus token = %q, space token should differ", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignCDNURLRejectsDuplicateQueryParameters(t *testing.T) {
|
||||
driver := &BunnyStorage{
|
||||
Addition: Addition{
|
||||
CDNTokenKey: "secret",
|
||||
SignURLExpire: 1,
|
||||
},
|
||||
}
|
||||
_, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high&quality=low", "", time.Unix(1700000000, 0))
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate query parameters to be rejected")
|
||||
}
|
||||
if got, want := err.Error(), `duplicate query parameter "quality" is not supported`; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBunnyTimeSupportsFractionalSecondsWithoutTimezone(t *testing.T) {
|
||||
fallback := time.Unix(1, 0)
|
||||
got := parseBunnyTime("2023-03-21T13:38:31.693", fallback)
|
||||
want := time.Date(2023, 3, 21, 13, 38, 31, 693000000, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("parsed time = %s, want %s", got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigProxyMode(t *testing.T) {
|
||||
registeredConfig := (&BunnyStorage{}).Config()
|
||||
if registeredConfig.OnlyProxy {
|
||||
t.Fatal("driver registration config should allow users to choose proxy policy")
|
||||
}
|
||||
withoutCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone"}}).Config()
|
||||
if !withoutCDN.OnlyProxy {
|
||||
t.Fatal("storage API links require AccessKey headers and should be proxied without CDN")
|
||||
}
|
||||
withCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone", CDNBaseURL: "https://zone.b-cdn.net"}}).Config()
|
||||
if withCDN.OnlyProxy {
|
||||
t.Fatal("CDN links should be allowed to redirect directly")
|
||||
}
|
||||
}
|
||||
+73
-35
@@ -105,44 +105,82 @@ func (ios *int_str) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 时间戳同样存在字符串/数字两种形式,且毫秒级时间戳会超出 int32 范围,需用 int64 解析
|
||||
// 超星 API 返回的 uploadDate 有三种格式:
|
||||
// 1. 数字时间戳:1780191356415
|
||||
// 2. 字符串时间戳:"1780191356415"
|
||||
// 3. 日期字符串:"2024-11-06 07:49"
|
||||
type int64_str int64
|
||||
|
||||
func (ios *int64_str) UnmarshalJSON(data []byte) error {
|
||||
// 去除引号
|
||||
str := string(bytes.Trim(data, "\""))
|
||||
|
||||
// 尝试直接解析为数字(处理格式 1 和 2)
|
||||
intValue, err := strconv.ParseInt(str, 10, 64)
|
||||
if err == nil {
|
||||
*ios = int64_str(intValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 尝试解析日期字符串(处理格式 3)
|
||||
// 支持格式:"2024-11-06 07:49" 或 "2024-11-06 07:49:30"
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, str); err == nil {
|
||||
// 转换为毫秒时间戳
|
||||
*ios = int64_str(t.UnixMilli())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 所有格式都失败,返回 0(避免因时间格式问题导致整个解析失败)
|
||||
*ios = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Cataid int `json:"cataid"`
|
||||
Cfid int `json:"cfid"`
|
||||
Content struct {
|
||||
Cfid int `json:"cfid"`
|
||||
Pid int `json:"pid"`
|
||||
FolderName string `json:"folderName"`
|
||||
ShareType int `json:"shareType"`
|
||||
Preview string `json:"preview"`
|
||||
Filetype string `json:"filetype"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
IsImg bool `json:"isImg"`
|
||||
ParentPath string `json:"parentPath"`
|
||||
Icon string `json:"icon"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration int `json:"duration"`
|
||||
Pantype string `json:"pantype"`
|
||||
Puid int_str `json:"puid"`
|
||||
Filepath string `json:"filepath"`
|
||||
Crc string `json:"crc"`
|
||||
Isfile bool `json:"isfile"`
|
||||
Residstr string `json:"residstr"`
|
||||
ObjectID string `json:"objectId"`
|
||||
Extinfo string `json:"extinfo"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Creator int `json:"creator"`
|
||||
ResTypeValue int `json:"resTypeValue"`
|
||||
UploadDateFormat string `json:"uploadDateFormat"`
|
||||
DisableOpt bool `json:"disableOpt"`
|
||||
DownPath string `json:"downPath"`
|
||||
Sort int `json:"sort"`
|
||||
Topsort int `json:"topsort"`
|
||||
Restype string `json:"restype"`
|
||||
Size int_str `json:"size"`
|
||||
UploadDate int64 `json:"uploadDate"`
|
||||
FileSize string `json:"fileSize"`
|
||||
Name string `json:"name"`
|
||||
FileID string `json:"fileId"`
|
||||
Cfid int `json:"cfid"`
|
||||
Pid int `json:"pid"`
|
||||
FolderName string `json:"folderName"`
|
||||
ShareType int `json:"shareType"`
|
||||
Preview string `json:"preview"`
|
||||
Filetype string `json:"filetype"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
IsImg bool `json:"isImg"`
|
||||
ParentPath string `json:"parentPath"`
|
||||
Icon string `json:"icon"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration int `json:"duration"`
|
||||
Pantype string `json:"pantype"`
|
||||
Puid int_str `json:"puid"`
|
||||
Filepath string `json:"filepath"`
|
||||
Crc string `json:"crc"`
|
||||
Isfile bool `json:"isfile"`
|
||||
Residstr string `json:"residstr"`
|
||||
ObjectID string `json:"objectId"`
|
||||
Extinfo string `json:"extinfo"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Creator int `json:"creator"`
|
||||
ResTypeValue int `json:"resTypeValue"`
|
||||
UploadDateFormat string `json:"uploadDateFormat"`
|
||||
DisableOpt bool `json:"disableOpt"`
|
||||
DownPath string `json:"downPath"`
|
||||
Sort int `json:"sort"`
|
||||
Topsort int `json:"topsort"`
|
||||
Restype string `json:"restype"`
|
||||
Size int_str `json:"size"`
|
||||
UploadDate int64_str `json:"uploadDate"`
|
||||
FileSize string `json:"fileSize"`
|
||||
Name string `json:"name"`
|
||||
FileID string `json:"fileId"`
|
||||
} `json:"content"`
|
||||
CreatorID int `json:"creatorId"`
|
||||
DesID string `json:"des_id"`
|
||||
@@ -265,7 +303,7 @@ func fileToObj(f File) *model.Object {
|
||||
IsFolder: true,
|
||||
}
|
||||
}
|
||||
paserTime := time.UnixMilli(f.Content.UploadDate)
|
||||
paserTime := time.UnixMilli(int64(f.Content.UploadDate))
|
||||
return &model.Object{
|
||||
ID: fmt.Sprintf("%d$%s", f.ID, f.Content.FileID),
|
||||
Name: f.Content.Name,
|
||||
|
||||
+64
-15
@@ -53,8 +53,8 @@ func (d *Chunk) Drop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Addition) GetRootPath() string {
|
||||
return "/"
|
||||
func (*Chunk) GetRootPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *Chunk) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
@@ -80,7 +80,7 @@ func (d *Chunk) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var totalSize int64 = 0
|
||||
var totalSize int64
|
||||
// 0号块默认为-1 以支持空文件
|
||||
chunkSizes := []int64{-1}
|
||||
h := make(map[*utils.HashType]string)
|
||||
@@ -119,6 +119,25 @@ func (d *Chunk) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
}
|
||||
}
|
||||
reqDir, _ := stdpath.Split(path)
|
||||
// 文件块不完整时,返回文件夹对象
|
||||
if chunkSizes[0] == -1 {
|
||||
return &model.Object{
|
||||
Path: stdpath.Join(reqDir, chunkName),
|
||||
Name: chunkName,
|
||||
IsFolder: true,
|
||||
Modified: d.Modified,
|
||||
}, nil
|
||||
}
|
||||
for i, l := 1, len(chunkSizes)-1; i < l; i++ {
|
||||
if chunkSizes[i] == 0 {
|
||||
return &model.Object{
|
||||
Path: stdpath.Join(reqDir, chunkName),
|
||||
Name: chunkName,
|
||||
IsFolder: true,
|
||||
Modified: d.Modified,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
objRes := chunkObject{
|
||||
Object: model.Object{
|
||||
Path: stdpath.Join(reqDir, chunkName),
|
||||
@@ -161,37 +180,69 @@ func (d *Chunk) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([
|
||||
result = append(result, nil)
|
||||
listG.Go(func(ctx context.Context) error {
|
||||
chunkObjs, err := op.List(ctx, remoteStorage, stdpath.Join(remoteActualDir, rawName), model.ListArgs{
|
||||
ReqPath: stdpath.Join(args.ReqPath, rawName),
|
||||
Refresh: args.Refresh,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalSize := int64(0)
|
||||
var totalSize int64
|
||||
// 0号块默认为-1 以支持空文件
|
||||
chunkSizes := []int64{-1}
|
||||
h := make(map[*utils.HashType]string)
|
||||
first := obj
|
||||
var first model.Obj
|
||||
for _, o := range chunkObjs {
|
||||
if o.IsDir() {
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(strings.TrimSuffix(o.GetName(), d.CustomExt), "hash_"); ok {
|
||||
hn, value, ok := strings.Cut(after, "_")
|
||||
if after, ok := strings.CutPrefix(o.GetName(), "hash_"); ok {
|
||||
hn, value, ok := strings.Cut(strings.TrimSuffix(after, d.CustomExt), "_")
|
||||
if ok {
|
||||
ht, ok := utils.GetHashByName(hn)
|
||||
if ok {
|
||||
h[ht] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSuffix(o.GetName(), d.CustomExt))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if idx == 0 {
|
||||
first = o
|
||||
}
|
||||
totalSize += o.GetSize()
|
||||
if len(chunkSizes) > idx {
|
||||
if idx == 0 {
|
||||
first = o
|
||||
}
|
||||
chunkSizes[idx] = o.GetSize()
|
||||
} else if len(chunkSizes) == idx {
|
||||
chunkSizes = append(chunkSizes, o.GetSize())
|
||||
} else {
|
||||
newChunkSizes := make([]int64, idx+1)
|
||||
copy(newChunkSizes, chunkSizes)
|
||||
chunkSizes = newChunkSizes
|
||||
chunkSizes[idx] = o.GetSize()
|
||||
}
|
||||
}
|
||||
// 文件块不完整时,返回文件夹对象
|
||||
if chunkSizes[0] == -1 {
|
||||
result[resultIdx] = &model.Object{
|
||||
Name: rawName,
|
||||
Size: obj.GetSize(),
|
||||
Modified: obj.ModTime(),
|
||||
IsFolder: true,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, l := 1, len(chunkSizes)-1; i < l; i++ {
|
||||
if chunkSizes[i] == 0 {
|
||||
result[resultIdx] = &model.Object{
|
||||
Name: rawName,
|
||||
Size: obj.GetSize(),
|
||||
Modified: obj.ModTime(),
|
||||
IsFolder: true,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
objRes := model.Object{
|
||||
Name: name,
|
||||
@@ -263,9 +314,7 @@ func (d *Chunk) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resultLink := *l
|
||||
resultLink.SyncClosers = utils.NewSyncClosers(l)
|
||||
return &resultLink, nil
|
||||
return l.Clone(), nil
|
||||
}
|
||||
// 检查0号块不等于-1 以支持空文件
|
||||
// 如果块数量大于1 最后一块不可能为0
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package cloudflare_imgbed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/cache"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type CFImgBed struct {
|
||||
model.Storage
|
||||
Addition
|
||||
client *resty.Client
|
||||
virtualDir *cache.WeakCacheMap[string, model.Object]
|
||||
publicUrlPrefix string
|
||||
}
|
||||
|
||||
func (d *CFImgBed) Config() driver.Config { return config }
|
||||
func (d *CFImgBed) GetAddition() driver.Additional { return &d.Addition }
|
||||
|
||||
func (d *CFImgBed) Init(ctx context.Context) error {
|
||||
d.UploadThread = min(d.UploadThread, 32)
|
||||
if d.UploadThread < 1 {
|
||||
d.UploadThread = 3
|
||||
}
|
||||
d.Address = strings.TrimRight(d.Address, "/")
|
||||
|
||||
d.client = base.NewRestyClient().
|
||||
SetBaseURL(d.Address).
|
||||
SetHeader("Authorization", "Bearer "+d.Token).
|
||||
SetDebug(false)
|
||||
|
||||
// 连通性测试:尝试获取根目录单条数据
|
||||
_, err := d.doRequest(ctx, http.MethodGet, listApi, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"start": "0",
|
||||
"count": "1",
|
||||
"dir": "/",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init verification failed: %w", err)
|
||||
}
|
||||
d.virtualDir = cache.NewWeakCacheMap[string, model.Object]()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *CFImgBed) Drop(ctx context.Context) error {
|
||||
if d.virtualDir != nil {
|
||||
d.virtualDir.Clear()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *CFImgBed) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
// if !args.Refresh && model.ObjHasMask(dir, model.Virtual) {
|
||||
// if _, ok := d.virtualDir.Load(dir.GetPath()); ok {
|
||||
// return nil, nil
|
||||
// }
|
||||
// }
|
||||
|
||||
var dirSeen map[string]bool
|
||||
var fileSeen map[string]bool
|
||||
var objs []model.Obj
|
||||
|
||||
start := 0
|
||||
for {
|
||||
var resp ListResponse
|
||||
_, err := d.doRequest(ctx, http.MethodGet, listApi, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"dir": dir.GetPath(),
|
||||
"start": fmt.Sprintf("%d", start),
|
||||
"count": fmt.Sprintf("%d", listPageSize),
|
||||
})
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Files) == 0 && len(resp.Directories) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if start == 0 {
|
||||
dirSeen = make(map[string]bool, len(resp.Directories))
|
||||
fileSeen = make(map[string]bool, len(resp.Files))
|
||||
objs = make([]model.Obj, 0, len(resp.Directories)+len(resp.Files))
|
||||
}
|
||||
|
||||
for _, rawDir := range resp.Directories {
|
||||
rawDir = "/" + strings.TrimRight(rawDir, "/")
|
||||
if !dirSeen[rawDir] {
|
||||
dirSeen[rawDir] = true
|
||||
objs = append(objs, &model.Object{
|
||||
Path: rawDir,
|
||||
Name: path.Base(rawDir),
|
||||
Modified: d.Modified,
|
||||
IsFolder: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range resp.Files {
|
||||
if !fileSeen[item.Name] {
|
||||
fileSeen[item.Name] = true
|
||||
objs = append(objs, parseFile(item))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前获取的数量少于分页大小,说明已加载完毕
|
||||
if len(resp.Files)+len(resp.Directories) < listPageSize {
|
||||
break
|
||||
}
|
||||
start += listPageSize
|
||||
}
|
||||
return objs, nil
|
||||
}
|
||||
|
||||
func (d *CFImgBed) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if d.publicUrlPrefix != "" {
|
||||
return &model.Link{URL: d.publicUrlPrefix + utils.EncodePath(file.GetPath())}, nil
|
||||
}
|
||||
return &model.Link{URL: d.Address + "/file" + utils.EncodePath(file.GetPath())}, nil
|
||||
}
|
||||
|
||||
func (d *CFImgBed) Get(ctx context.Context, pathStr string) (model.Obj, error) {
|
||||
fullPath := path.Join(d.RootFolderPath, pathStr)
|
||||
if obj, found := d.virtualDir.Load(fullPath); found {
|
||||
return obj, nil
|
||||
}
|
||||
return nil, errs.NotSupport
|
||||
}
|
||||
|
||||
// MakeDir 在图床中通常是虚拟的,此处返回虚拟目录对象以支持上传时的路径展示
|
||||
func (d *CFImgBed) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
|
||||
fullPath := path.Join(parentDir.GetPath(), dirName)
|
||||
temp := &model.Object{
|
||||
Path: fullPath,
|
||||
Name: dirName,
|
||||
IsFolder: true,
|
||||
Modified: d.Modified,
|
||||
Mask: model.Virtual,
|
||||
}
|
||||
d.virtualDir.Store(fullPath, temp)
|
||||
return temp, nil
|
||||
}
|
||||
|
||||
func (d *CFImgBed) Remove(ctx context.Context, obj model.Obj) error {
|
||||
reqPath := obj.GetPath()
|
||||
if model.ObjHasMask(obj, model.Virtual) {
|
||||
d.virtualDir.Delete(reqPath)
|
||||
return nil
|
||||
}
|
||||
_, err := d.doRequest(ctx, http.MethodPost, deleteApi+utils.EncodePath(reqPath), func(req *resty.Request) {
|
||||
req.SetQueryParam("folder", fmt.Sprintf("%t", obj.IsDir()))
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*CFImgBed)(nil)
|
||||
@@ -0,0 +1,27 @@
|
||||
package cloudflare_imgbed
|
||||
|
||||
import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
driver.RootPath
|
||||
Address string `json:"address" required:"true" help:"Backend API address of the image hosting service, e.g., https://img.example.com"`
|
||||
Token string `json:"token" required:"true" help:"Authentication Token"`
|
||||
SmallChannelName string `json:"smallChannelName" help:"Channel name for regular files (typically <20MB)"`
|
||||
LargeChannelName string `json:"largeChannelName" help:"Channel name for large files"`
|
||||
LargeChannelType string `json:"largeChannelType" type:"select" options:",huggingface,telegram,cfr2,s3,discord" help:"Large File Channel Type: Hugging Face (Direct Upload)、telegram/cfr2/s3/discord(Multipart Upload)"`
|
||||
UploadThread int `json:"uploadThread" type:"number" default:"3" help:"Concurrent thread count for HuggingFace chunked direct upload"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "cloudflare_imgbed",
|
||||
LocalSort: true,
|
||||
NoUpload: false,
|
||||
DefaultRoot: "/",
|
||||
}
|
||||
|
||||
func init() {
|
||||
op.RegisterDriver(func() driver.Driver { return &CFImgBed{} })
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package cloudflare_imgbed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
)
|
||||
|
||||
const listPageSize = 1000
|
||||
|
||||
// ListResponse 列表接口响应
|
||||
type ListResponse struct {
|
||||
Files []FileItem `json:"files"`
|
||||
Directories []string `json:"directories"`
|
||||
}
|
||||
|
||||
type FileItem struct {
|
||||
Name string `json:"name"`
|
||||
Metadata map[string]interface{} `json:"metadata"` // 存储文件大小、哈希、时间戳等
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// standardUploadResp 标准上传成功返回的数组
|
||||
type standardUploadResp []struct {
|
||||
Src string `json:"src"`
|
||||
PublicUrl string `json:"publicUrl"`
|
||||
}
|
||||
|
||||
// hfGetUrlResp 获取 HF 直传授权地址的响应
|
||||
type hfGetUrlResp struct {
|
||||
Success bool `json:"success"`
|
||||
FullID string `json:"fullId"`
|
||||
FilePath string `json:"filePath"`
|
||||
ChannelName string `json:"channelName"`
|
||||
Repo string `json:"repo"`
|
||||
NeedsLfs bool `json:"needsLfs"` // 是否需要进行 LFS 物理上传
|
||||
AlreadyExists bool `json:"alreadyExists"` // 是否秒传成功
|
||||
Oid string `json:"oid"` // Git LFS 对象 ID (SHA256)
|
||||
UploadAction *UploadAction `json:"uploadAction"`
|
||||
}
|
||||
|
||||
type UploadAction struct {
|
||||
Href string `json:"href"`
|
||||
Header map[string]string `json:"header"`
|
||||
}
|
||||
|
||||
type hfCommitResp struct {
|
||||
Success bool `json:"success"`
|
||||
Src string `json:"src"`
|
||||
PublicUrl string `json:"publicUrl"`
|
||||
FileUrl string `json:"fileUrl"`
|
||||
FullID string `json:"fullId"`
|
||||
}
|
||||
|
||||
// 辅助函数:从 map 中安全提取字符串/数值
|
||||
func getString(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt64(m map[string]interface{}, keys ...string) int64 {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(val, 10, 64)
|
||||
return n
|
||||
case float64:
|
||||
return int64(val)
|
||||
case int64:
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseFile(item FileItem) *model.Object {
|
||||
name := path.Base(item.Name)
|
||||
var size int64
|
||||
var modTime time.Time
|
||||
|
||||
if item.Metadata != nil {
|
||||
size = getInt64(item.Metadata, "FileSizeBytes", "File-Size")
|
||||
ts := getInt64(item.Metadata, "TimeStamp")
|
||||
if ts > 0 {
|
||||
modTime = time.UnixMilli(ts)
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Object{
|
||||
Path: "/" + strings.TrimRight(item.Name, "/"),
|
||||
Name: name,
|
||||
Size: size,
|
||||
Modified: modTime,
|
||||
IsFolder: false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package cloudflare_imgbed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"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/stream"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/errgroup"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/avast/retry-go"
|
||||
"github.com/go-resty/resty/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (d *CFImgBed) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (newObj model.Obj, err error) {
|
||||
if file.GetSize() < hfDirectThreshold {
|
||||
newObj, err = d.standardUpload(ctx, dstDir, file, up)
|
||||
} else {
|
||||
switch d.LargeChannelType {
|
||||
case "huggingface":
|
||||
newObj, err = d.hfDirectUpload(ctx, dstDir, file, up)
|
||||
case "telegram", "cfr2", "s3", "discord":
|
||||
newObj, err = d.chunkedUpload(ctx, dstDir, file, up, d.LargeChannelType, d.LargeChannelName)
|
||||
default:
|
||||
newObj, err = d.standardUpload(ctx, dstDir, file, up)
|
||||
}
|
||||
}
|
||||
if newObj != nil && model.ObjHasMask(dstDir, model.Virtual) {
|
||||
key := dstDir.GetPath()
|
||||
for d.virtualDir.Delete(key) {
|
||||
key = path.Dir(key)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// standardUpload 通过普通 multipart 表单上传。
|
||||
// 使用 io.MultiReader 实现虚拟拼接,避免将整个大文件读入内存构建表单。
|
||||
func (d *CFImgBed) standardUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
||||
|
||||
channelName := d.SmallChannelName
|
||||
if file.GetSize() >= hfDirectThreshold {
|
||||
channelName = d.LargeChannelName
|
||||
log.WithField("size", file.GetSize()).Warn("large file falls back to standard upload, consider configuring LargeChannelType")
|
||||
}
|
||||
if channelName == "" {
|
||||
return nil, fmt.Errorf("channel name not configured")
|
||||
}
|
||||
|
||||
// 1. 将参数放入 Query String
|
||||
reqUrl, err := url.Parse(d.Address + uploadApi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := reqUrl.Query()
|
||||
q.Set("returnFormat", "default")
|
||||
q.Set("channelName", channelName)
|
||||
q.Set("uploadFolder", dstDir.GetPath())
|
||||
q.Set("autoRetry", "true")
|
||||
reqUrl.RawQuery = q.Encode()
|
||||
|
||||
// 2. 构建 multipart 表单的头部
|
||||
b := bytes.NewBuffer(make([]byte, 0, 164+len(file.GetName()))) // 预估头部大小,避免频繁扩容
|
||||
w := multipart.NewWriter(b)
|
||||
_, err = w.CreateFormFile("file", file.GetName())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headSize := b.Len()
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head := bytes.NewReader(b.Bytes()[:headSize])
|
||||
tail := bytes.NewReader(b.Bytes()[headSize:])
|
||||
|
||||
// 3. 将 [表单头 + 文件流 + 表单尾] 组合成单一 Reader
|
||||
rateLimitedReader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
|
||||
Reader: &driver.SimpleReaderWithSize{
|
||||
Reader: io.MultiReader(head, file, tail),
|
||||
Size: int64(b.Len()) + file.GetSize(),
|
||||
},
|
||||
UpdateProgress: up,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl.String(), rateLimitedReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+d.Token)
|
||||
req.ContentLength = int64(b.Len()) + file.GetSize()
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
b.Reset()
|
||||
_, err = b.ReadFrom(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("upload failed %d: %s", res.StatusCode, b.String())
|
||||
}
|
||||
|
||||
var resp standardUploadResp
|
||||
if err := json.Unmarshal(b.Bytes(), &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 || resp[0].Src == "" {
|
||||
return nil, fmt.Errorf("no src returned")
|
||||
}
|
||||
|
||||
srcPath := strings.TrimPrefix(resp[0].Src, "/file/")
|
||||
srcPath = strings.TrimPrefix(srcPath, "/")
|
||||
|
||||
if resp[0].PublicUrl != "" {
|
||||
if u, err := url.Parse(resp[0].PublicUrl); err == nil {
|
||||
d.publicUrlPrefix = u.Scheme + "://" + u.Host
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Object{
|
||||
Path: srcPath,
|
||||
Name: file.GetName(),
|
||||
Size: file.GetSize(),
|
||||
Modified: file.ModTime(),
|
||||
IsFolder: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *CFImgBed) chunkedUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, channelType, channelName string) (model.Obj, error) {
|
||||
if channelName == "" {
|
||||
return nil, fmt.Errorf("channel name not configured for chunked upload")
|
||||
}
|
||||
|
||||
fileSize := file.GetSize()
|
||||
fileMime := file.GetMimetype()
|
||||
fileName := file.GetName()
|
||||
|
||||
var chunkSizeMap = map[string]int64{
|
||||
|
||||
}
|
||||
chunkSize := chunkSizeMap[channelType]
|
||||
if chunkSize == 0 {
|
||||
chunkSize = 5 * 1024 * 1024
|
||||
}
|
||||
totalChunks := int((fileSize + chunkSize - 1) / chunkSize)
|
||||
|
||||
// 第一步:initChunked
|
||||
var initResp struct {
|
||||
Success bool `json:"success"`
|
||||
UploadId string `json:"uploadId"`
|
||||
}
|
||||
_, err := d.doRequest(ctx, http.MethodPost, uploadApi, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"initChunked": "true",
|
||||
"uploadChannel": channelType,
|
||||
"channelName": channelName,
|
||||
})
|
||||
req.SetFormData(map[string]string{
|
||||
"originalFileName": fileName,
|
||||
"originalFileType": fileMime,
|
||||
"totalChunks": strconv.Itoa(totalChunks),
|
||||
})
|
||||
}, &initResp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initChunked failed: %w", err)
|
||||
}
|
||||
if !initResp.Success || initResp.UploadId == "" {
|
||||
return nil, fmt.Errorf("initChunked returned no uploadId")
|
||||
}
|
||||
uploadId := initResp.UploadId
|
||||
|
||||
// 第二步:逐块上传
|
||||
ss, err := stream.NewStreamSectionReader(file, int(chunkSize), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqUrl := d.Address + uploadApi + "?chunked=true&uploadChannel=" + channelType + "&channelName=" + channelName
|
||||
b := bytes.NewBuffer(make([]byte, 0, 2048))
|
||||
|
||||
g, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, min(d.UploadThread, totalChunks),
|
||||
retry.Attempts(3),
|
||||
retry.Delay(time.Second),
|
||||
retry.DelayType(retry.BackOffDelay))
|
||||
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
if utils.IsCanceled(uploadCtx) {
|
||||
break
|
||||
}
|
||||
chunkIndex := i
|
||||
offset := int64(chunkIndex) * chunkSize
|
||||
sizeToRead := chunkSize
|
||||
if offset+sizeToRead > fileSize {
|
||||
sizeToRead = fileSize - offset
|
||||
}
|
||||
|
||||
var reader io.ReadSeeker
|
||||
g.GoWithLifecycle(errgroup.Lifecycle{
|
||||
Before: func(ctx context.Context) (err error) {
|
||||
reader, err = ss.GetSectionReader(offset, sizeToRead)
|
||||
return
|
||||
},
|
||||
After: func(err error) {
|
||||
ss.FreeSectionReader(reader)
|
||||
},
|
||||
Do: func(ctx context.Context) (err error) {
|
||||
_, err = reader.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
w := multipart.NewWriter(b)
|
||||
_ = w.WriteField("uploadId", uploadId)
|
||||
_ = w.WriteField("chunkIndex", strconv.Itoa(chunkIndex))
|
||||
_ = w.WriteField("totalChunks", strconv.Itoa(totalChunks))
|
||||
_ = w.WriteField("originalFileName", fileName)
|
||||
_ = w.WriteField("originalFileType", fileMime)
|
||||
_, _ = w.CreateFormFile("file", fileName)
|
||||
headSize := b.Len()
|
||||
_ = w.Close()
|
||||
head := bytes.NewReader(b.Bytes()[:headSize])
|
||||
tail := bytes.NewReader(b.Bytes()[headSize:])
|
||||
|
||||
rateLimitedRd := driver.NewLimitedUploadStream(ctx, io.MultiReader(head, reader, tail))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, rateLimitedRd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+d.Token)
|
||||
req.ContentLength = int64(headSize) + sizeToRead + int64(b.Len()-headSize)
|
||||
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("chunk %d upload failed: %d", chunkIndex, res.StatusCode)
|
||||
}
|
||||
|
||||
up(90 * float64(chunkIndex+1) / float64(totalChunks))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 第三步:merge
|
||||
var mergeResp standardUploadResp
|
||||
_, err = d.doRequest(ctx, http.MethodPost, uploadApi, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"chunked": "true",
|
||||
"merge": "true",
|
||||
"uploadChannel": channelType,
|
||||
"channelName": channelName,
|
||||
"returnFormat": "default",
|
||||
"uploadFolder": dstDir.GetPath(),
|
||||
})
|
||||
req.SetFormData(map[string]string{
|
||||
"uploadId": uploadId,
|
||||
"totalChunks": strconv.Itoa(totalChunks),
|
||||
"originalFileName": fileName,
|
||||
"originalFileType": fileMime,
|
||||
})
|
||||
}, &mergeResp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge failed: %w", err)
|
||||
}
|
||||
if len(mergeResp) == 0 || mergeResp[0].Src == "" {
|
||||
return nil, fmt.Errorf("merge returned no src")
|
||||
}
|
||||
|
||||
up(95)
|
||||
|
||||
srcPath := strings.TrimPrefix(mergeResp[0].Src, "/file/")
|
||||
srcPath = strings.TrimPrefix(srcPath, "/")
|
||||
|
||||
if mergeResp[0].PublicUrl != "" {
|
||||
if u, err := url.Parse(mergeResp[0].PublicUrl); err == nil {
|
||||
d.publicUrlPrefix = u.Scheme + "://" + u.Host
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Object{
|
||||
Path: srcPath,
|
||||
Name: fileName,
|
||||
Size: fileSize,
|
||||
Modified: file.ModTime(),
|
||||
IsFolder: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hfDirectUpload 处理 HuggingFace 的 LFS 直传逻辑(申请授权 -> 物理上传 -> 后端 Commit)
|
||||
func (d *CFImgBed) hfDirectUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
||||
channelName := d.LargeChannelName
|
||||
if channelName == "" {
|
||||
return nil, errors.New("LargeChannelName not configured")
|
||||
}
|
||||
|
||||
sha256Hash := file.GetHash().GetHash(utils.SHA256)
|
||||
if len(sha256Hash) != utils.SHA256.Width {
|
||||
var err error
|
||||
_, sha256Hash, err = stream.CacheFullAndHash(file, &up, utils.SHA256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
fileSize := file.GetSize()
|
||||
sampleSize := min(fileSize, fileSampleSize)
|
||||
sampleRd, err := file.RangeRead(http_range.Range{Start: 0, Length: sampleSize})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sampleBuf := make([]byte, sampleSize)
|
||||
_, err = io.ReadFull(sampleRd, sampleBuf)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return nil, err
|
||||
}
|
||||
fileSample := base64.StdEncoding.EncodeToString(sampleBuf)
|
||||
|
||||
fileMime := file.GetMimetype()
|
||||
// 1. 请求图床后端获取 HF 授权地址
|
||||
reqBody := map[string]interface{}{
|
||||
"fileName": file.GetName(),
|
||||
"fileType": fileMime,
|
||||
"fileSize": fileSize,
|
||||
"sha256": sha256Hash,
|
||||
"fileSample": fileSample,
|
||||
"channelName": channelName,
|
||||
"uploadFolder": dstDir.GetPath(),
|
||||
}
|
||||
|
||||
var getUrlResp hfGetUrlResp
|
||||
_, err = d.doRequest(ctx, http.MethodPost, hfGetUrlApi, func(req *resty.Request) {
|
||||
req.SetBody(reqBody)
|
||||
req.SetHeader("Content-Type", "application/json")
|
||||
}, &getUrlResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 秒传逻辑
|
||||
if getUrlResp.AlreadyExists || !getUrlResp.NeedsLfs {
|
||||
up(100)
|
||||
return d.hfCommit(ctx, getUrlResp, file.GetName(), fileSize, fileMime, file.ModTime())
|
||||
}
|
||||
|
||||
if getUrlResp.UploadAction == nil {
|
||||
return nil, fmt.Errorf("HF upload action is nil")
|
||||
}
|
||||
|
||||
headers := getUrlResp.UploadAction.Header
|
||||
href := getUrlResp.UploadAction.Href
|
||||
|
||||
// 2. 根据响应判断是执行分片上传还是单文件上传
|
||||
chunkSizeStr, needChunk := headers["chunk_size"]
|
||||
if needChunk {
|
||||
// 分片直传 (AWS S3 Multipart 风格)
|
||||
chunkSize, _ := strconv.ParseInt(chunkSizeStr, 10, 64)
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 20 * 1024 * 1024
|
||||
}
|
||||
|
||||
partUrls := make(map[int]string)
|
||||
for k, v := range headers {
|
||||
if k != "chunk_size" {
|
||||
if idx, err := strconv.Atoi(k); err == nil {
|
||||
partUrls[idx] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
totalParts := len(partUrls)
|
||||
|
||||
ss, err := stream.NewStreamSectionReader(file, int(chunkSize), &up)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, uploadCtx := errgroup.NewOrderedGroupWithContext(ctx, min(d.UploadThread, totalParts),
|
||||
retry.Attempts(3),
|
||||
retry.Delay(time.Second),
|
||||
retry.DelayType(retry.BackOffDelay))
|
||||
|
||||
parts := make([]map[string]any, totalParts)
|
||||
|
||||
for partNumber := range partUrls {
|
||||
if utils.IsCanceled(uploadCtx) {
|
||||
break
|
||||
}
|
||||
partUrl := partUrls[partNumber]
|
||||
offset := int64(partNumber-1) * chunkSize
|
||||
sizeToRead := chunkSize
|
||||
if offset+sizeToRead > fileSize {
|
||||
sizeToRead = fileSize - offset
|
||||
}
|
||||
|
||||
var reader io.ReadSeeker
|
||||
g.GoWithLifecycle(errgroup.Lifecycle{
|
||||
Before: func(ctx context.Context) (err error) {
|
||||
reader, err = ss.GetSectionReader(offset, sizeToRead)
|
||||
return
|
||||
},
|
||||
After: func(err error) {
|
||||
ss.FreeSectionReader(reader)
|
||||
},
|
||||
Do: func(ctx context.Context) (err error) {
|
||||
_, err = reader.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limitedReader := driver.NewLimitedUploadStream(ctx, reader)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, partUrl, limitedReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.ContentLength = sizeToRead
|
||||
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("chunk %d failed: %d", partNumber, res.StatusCode)
|
||||
}
|
||||
|
||||
etag := res.Header.Get("ETag")
|
||||
parts[partNumber-1] = map[string]any{"partNumber": partNumber, "etag": etag}
|
||||
|
||||
up(95 * float64(g.Success()+1) / float64(totalParts))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 合并分片
|
||||
// sort.Slice(parts, func(i, j int) bool { return parts[i]["partNumber"].(int) < parts[j]["partNumber"].(int) })
|
||||
mergeBody, _ := json.Marshal(map[string]any{"oid": getUrlResp.Oid, "parts": parts})
|
||||
mergeReq, err := http.NewRequestWithContext(ctx, http.MethodPost, href, bytes.NewReader(mergeBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mergeReq.Header.Set("Content-Type", "application/vnd.git-lfs+json")
|
||||
if d.Token != "" {
|
||||
mergeReq.Header.Set("Authorization", "Bearer "+d.Token)
|
||||
}
|
||||
|
||||
res, err := base.HttpClient.Do(mergeReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body) // 读取 HF 返回的错误详情
|
||||
if res.StatusCode != http.StatusOK {
|
||||
log.WithField("status", res.StatusCode).WithField("response", string(body)).Error("HF merge chunks failed")
|
||||
return nil, fmt.Errorf("merge chunks failed: %s", string(body))
|
||||
}
|
||||
up(97)
|
||||
|
||||
} else {
|
||||
// 单文件直传 (PUT)
|
||||
limitedReader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
|
||||
Reader: file,
|
||||
UpdateProgress: model.UpdateProgressWithRange(up, 0, 97),
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, href, limitedReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.ContentLength = fileSize
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
res, err := base.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("direct upload failed")
|
||||
}
|
||||
}
|
||||
|
||||
defer up(100)
|
||||
|
||||
// 3. 通知图床后端完成文件登记
|
||||
return d.hfCommit(ctx, getUrlResp, file.GetName(), fileSize, fileMime, file.ModTime())
|
||||
}
|
||||
|
||||
func (d *CFImgBed) hfCommit(ctx context.Context, getUrlResp hfGetUrlResp, fileName string, fileSize int64, fileMime string, modTime time.Time) (model.Obj, error) {
|
||||
commitBody := map[string]interface{}{
|
||||
"fullId": getUrlResp.FullID,
|
||||
"filePath": getUrlResp.FilePath,
|
||||
"sha256": getUrlResp.Oid,
|
||||
"fileSize": fileSize,
|
||||
"fileName": fileName,
|
||||
"fileType": fileMime,
|
||||
"channelName": getUrlResp.ChannelName,
|
||||
}
|
||||
var commitResp hfCommitResp
|
||||
_, err := d.doRequest(ctx, http.MethodPost, hfCommitApi, func(req *resty.Request) {
|
||||
req.SetBody(commitBody)
|
||||
}, &commitResp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HF commit request failed: %w", err)
|
||||
}
|
||||
if !commitResp.Success {
|
||||
return nil, fmt.Errorf("HF commit failed: success=false")
|
||||
}
|
||||
|
||||
srcPath := strings.TrimPrefix(commitResp.Src, "/file/")
|
||||
srcPath = strings.TrimPrefix(srcPath, "/")
|
||||
|
||||
if commitResp.PublicUrl != "" {
|
||||
if u, err := url.Parse(commitResp.PublicUrl); err == nil {
|
||||
d.publicUrlPrefix = u.Scheme + "://" + u.Host
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Object{
|
||||
Path: srcPath,
|
||||
Name: fileName,
|
||||
Size: fileSize,
|
||||
Modified: modTime,
|
||||
IsFolder: false,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cloudflare_imgbed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
listApi = "/api/manage/list"
|
||||
deleteApi = "/api/manage/delete"
|
||||
uploadApi = "/upload"
|
||||
hfGetUrlApi = "/upload/huggingface/getUploadUrl"
|
||||
hfCommitApi = "/upload/huggingface/commitUpload"
|
||||
hfDirectThreshold int64 = 20 * 1024 * 1024
|
||||
fileSampleSize = 512 // HF 申请上传地址时需提供文件前 512 字节的 Sample
|
||||
)
|
||||
|
||||
// doRequest 通用请求封装,包含重试和 API 错误解析
|
||||
func (d *CFImgBed) doRequest(ctx context.Context, method, urlPath string, callback func(*resty.Request), resp interface{}) ([]byte, error) {
|
||||
maxRetries := 3
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
req := d.client.R()
|
||||
req.SetContext(ctx)
|
||||
if callback != nil {
|
||||
callback(req)
|
||||
}
|
||||
if resp != nil {
|
||||
req.SetResult(resp)
|
||||
}
|
||||
|
||||
res, err := req.Execute(method, urlPath)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("request %s %s failed, attempt %d/%d", method, urlPath, i+1, maxRetries)
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(time.Duration(i+1) * time.Second)
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Retry on rate limit before attempting to interpret the body as an API error.
|
||||
if res.StatusCode() == 429 {
|
||||
time.Sleep(time.Duration(i+1) * 2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
body := res.Body()
|
||||
var apiErr apiError
|
||||
if err := json.Unmarshal(body, &apiErr); err == nil {
|
||||
if apiErr.Error != "" || apiErr.Message != "" {
|
||||
msg := apiErr.Error
|
||||
if msg == "" {
|
||||
msg = apiErr.Message
|
||||
}
|
||||
return nil, fmt.Errorf("API error: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
if res.IsError() {
|
||||
return nil, fmt.Errorf("HTTP %d", res.StatusCode())
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
return nil, fmt.Errorf("max retries exceeded")
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func (d *Crypt) Link(ctx context.Context, file model.Obj, _ model.LinkArgs) (*mo
|
||||
rrf, err := stream.GetRangeReaderFromLink(remoteSize, remoteLink)
|
||||
if err != nil {
|
||||
_ = remoteLink.Close()
|
||||
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support encrytion")
|
||||
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support encryption")
|
||||
}
|
||||
|
||||
mu := &sync.Mutex{}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -300,7 +301,10 @@ func (d *DoubaoNew) Put(ctx context.Context, dstDir model.Obj, file model.FileSt
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
defer func() {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}()
|
||||
|
||||
blockSize := uploadPrep.BlockSize
|
||||
totalSize := file.GetSize()
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package emby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
)
|
||||
|
||||
type Emby struct {
|
||||
model.Storage
|
||||
Addition
|
||||
|
||||
client *http.Client
|
||||
token string
|
||||
userID string
|
||||
}
|
||||
|
||||
func (d *Emby) Config() driver.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
func (d *Emby) GetAddition() driver.Additional {
|
||||
return &d.Addition
|
||||
}
|
||||
|
||||
func (d *Emby) Init(ctx context.Context) error {
|
||||
d.URL = strings.TrimRight(strings.TrimSpace(d.URL), "/")
|
||||
if d.URL == "" {
|
||||
return fmt.Errorf("url is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(d.RootFolderID) == "" {
|
||||
d.RootFolderID = "1"
|
||||
}
|
||||
|
||||
d.client = base.HttpClient
|
||||
d.token = strings.TrimSpace(d.ApiKey)
|
||||
d.userID = strings.TrimSpace(d.UserID)
|
||||
|
||||
if d.token != "" {
|
||||
if d.userID == "" {
|
||||
return fmt.Errorf("user_id is required when api_key is set")
|
||||
}
|
||||
op.MustSaveDriverStorage(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(d.Username) == "" {
|
||||
return fmt.Errorf("please provide api_key+user_id or username(+password)")
|
||||
}
|
||||
|
||||
if err := d.login(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.ApiKey = d.token
|
||||
d.UserID = d.userID
|
||||
op.MustSaveDriverStorage(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Emby) Drop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Emby) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
parentID := strings.TrimSpace(d.RootFolderID)
|
||||
if dir != nil && strings.TrimSpace(dir.GetID()) != "" {
|
||||
parentID = strings.TrimSpace(dir.GetID())
|
||||
}
|
||||
|
||||
items, err := d.getItems(ctx, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentPath := "/"
|
||||
if dir != nil && strings.TrimSpace(dir.GetPath()) != "" {
|
||||
parentPath = dir.GetPath()
|
||||
}
|
||||
|
||||
objs := make([]model.Obj, 0, len(items.Items))
|
||||
for _, it := range items.Items {
|
||||
modified := time.Now()
|
||||
if it.DateCreated != "" {
|
||||
if t, parseErr := time.Parse(time.RFC3339Nano, it.DateCreated); parseErr == nil {
|
||||
modified = t
|
||||
}
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(it.Name)
|
||||
id := strings.TrimSpace(it.ID)
|
||||
displayName := name
|
||||
if name != "" && id != "" {
|
||||
if it.IsFolder {
|
||||
displayName = fmt.Sprintf("%s (ID%s)", name, id)
|
||||
} else {
|
||||
ext := path.Ext(strings.TrimSpace(it.Path))
|
||||
if ext == "" {
|
||||
ext = path.Ext(name)
|
||||
}
|
||||
|
||||
base := strings.TrimSpace(strings.TrimSuffix(name, ext))
|
||||
episodeCode := ""
|
||||
if m := episodeCodeRegexp.FindString(base); m != "" {
|
||||
episodeCode = strings.ToUpper(m)
|
||||
} else if it.ParentIndex > 0 && it.IndexNumber > 0 {
|
||||
episodeCode = fmt.Sprintf("S%02dE%02d", it.ParentIndex, it.IndexNumber)
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(base)
|
||||
if episodeCode != "" {
|
||||
title = strings.TrimSpace(episodeCodeRegexp.ReplaceAllString(title, ""))
|
||||
title = strings.TrimSpace(strings.Trim(title, "-_:[]() "))
|
||||
}
|
||||
|
||||
series := strings.TrimSpace(it.SeriesName)
|
||||
if series == "" && episodeCode != "" {
|
||||
if idx := strings.Index(title, " - "); idx > 0 {
|
||||
series = strings.TrimSpace(title[:idx])
|
||||
title = strings.TrimSpace(title[idx+3:])
|
||||
}
|
||||
}
|
||||
|
||||
core := title
|
||||
if series != "" {
|
||||
if title == "" || strings.EqualFold(series, title) {
|
||||
core = series
|
||||
} else {
|
||||
core = series + " " + title
|
||||
}
|
||||
}
|
||||
if core == "" {
|
||||
core = base
|
||||
}
|
||||
|
||||
if episodeCode != "" {
|
||||
core = fmt.Sprintf("%s - [%s]", core, episodeCode)
|
||||
}
|
||||
if ext == "" {
|
||||
displayName = fmt.Sprintf("%s (ID%s)", core, id)
|
||||
} else {
|
||||
displayName = fmt.Sprintf("%s (ID%s)%s", core, id, ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj := &model.Object{
|
||||
ID: id,
|
||||
Name: displayName,
|
||||
Path: path.Join(parentPath, displayName),
|
||||
Size: it.Size,
|
||||
Modified: modified,
|
||||
IsFolder: it.IsFolder,
|
||||
}
|
||||
if it.IsFolder {
|
||||
obj.Size = 0
|
||||
}
|
||||
objs = append(objs, obj)
|
||||
}
|
||||
return objs, nil
|
||||
}
|
||||
|
||||
func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if file.IsDir() {
|
||||
return nil, errs.NotFile
|
||||
}
|
||||
fileID := strings.TrimSpace(file.GetID())
|
||||
if fileID == "" {
|
||||
return nil, fmt.Errorf("invalid file id")
|
||||
}
|
||||
|
||||
u, err := url.Parse(d.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linkMethod := strings.ToLower(strings.TrimSpace(d.LinkMethod))
|
||||
useDownload := linkMethod == "download"
|
||||
|
||||
mediaSourceID := ""
|
||||
mediaContainer := ""
|
||||
if !useDownload {
|
||||
detailURL, parseErr := url.Parse(d.URL + "/Users/" + d.userID + "/Items/" + fileID)
|
||||
if parseErr == nil {
|
||||
q := detailURL.Query()
|
||||
q.Set("Fields", "MediaSources")
|
||||
q.Set("api_key", d.token)
|
||||
detailURL.RawQuery = q.Encode()
|
||||
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, detailURL.String(), nil)
|
||||
if reqErr == nil {
|
||||
resp, doErr := d.client.Do(req)
|
||||
if doErr == nil {
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return
|
||||
}
|
||||
var detail itemDetailResp
|
||||
if decodeErr := json.NewDecoder(resp.Body).Decode(&detail); decodeErr != nil || len(detail.MediaSources) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range detail.MediaSources {
|
||||
if strings.TrimSpace(detail.MediaSources[i].ID) != "" && detail.MediaSources[i].SupportsDirectStream {
|
||||
mediaSourceID = strings.TrimSpace(detail.MediaSources[i].ID)
|
||||
mediaContainer = strings.TrimSpace(detail.MediaSources[i].Container)
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := range detail.MediaSources {
|
||||
if strings.TrimSpace(detail.MediaSources[i].ID) != "" {
|
||||
mediaSourceID = strings.TrimSpace(detail.MediaSources[i].ID)
|
||||
mediaContainer = strings.TrimSpace(detail.MediaSources[i].Container)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if useDownload {
|
||||
u.Path = path.Join(u.Path, "/Items", fileID, "Download")
|
||||
} else {
|
||||
if mediaContainer != "" {
|
||||
u.Path = path.Join(u.Path, "/Videos", fileID, "stream."+mediaContainer)
|
||||
} else {
|
||||
u.Path = path.Join(u.Path, "/Videos", fileID, "stream")
|
||||
}
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("api_key", d.token)
|
||||
if mediaSourceID != "" {
|
||||
q.Set("MediaSourceId", mediaSourceID)
|
||||
}
|
||||
if !useDownload {
|
||||
q.Set("Static", "true")
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return &model.Link{
|
||||
URL: u.String(),
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{base.UserAgent},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Emby)(nil)
|
||||
@@ -0,0 +1,30 @@
|
||||
package emby
|
||||
|
||||
import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
driver.RootID
|
||||
URL string `json:"url" required:"true"`
|
||||
ApiKey string `json:"api_key"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LinkMethod string `json:"link_method" type:"select" options:"stream,download" default:"stream"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "Emby",
|
||||
LocalSort: true,
|
||||
NoUpload: true,
|
||||
DefaultRoot: "1",
|
||||
CheckStatus: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
op.RegisterDriver(func() driver.Driver {
|
||||
return &Emby{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package emby
|
||||
|
||||
type authReq struct {
|
||||
Username string `json:"Username"`
|
||||
Pw string `json:"Pw"`
|
||||
}
|
||||
|
||||
type authResp struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
} `json:"User"`
|
||||
}
|
||||
|
||||
type listResp struct {
|
||||
Items []embyItem `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
type embyItem struct {
|
||||
Name string `json:"Name"`
|
||||
ID string `json:"Id"`
|
||||
Type string `json:"Type"`
|
||||
Path string `json:"Path"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
ParentIndex int `json:"ParentIndexNumber"`
|
||||
IsFolder bool `json:"IsFolder"`
|
||||
Size int64 `json:"Size"`
|
||||
DateCreated string `json:"DateCreated"`
|
||||
}
|
||||
|
||||
type itemDetailResp struct {
|
||||
MediaSources []embyMediaSource `json:"MediaSources"`
|
||||
}
|
||||
|
||||
type embyMediaSource struct {
|
||||
ID string `json:"Id"`
|
||||
Container string `json:"Container"`
|
||||
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package emby
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var episodeCodeRegexp = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,2}\b`)
|
||||
|
||||
func (d *Emby) login(ctx context.Context) error {
|
||||
payload, err := json.Marshal(authReq{
|
||||
Username: d.Username,
|
||||
Pw: d.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoint := d.URL + "/Users/AuthenticateByName"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="OpenList", Device="OpenList", DeviceId="openlist-emby", Version="1.0.0"`)
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("emby auth failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var data authResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(data.AccessToken) == "" || strings.TrimSpace(data.User.ID) == "" {
|
||||
return fmt.Errorf("emby auth response missing access token or user id")
|
||||
}
|
||||
|
||||
d.token = data.AccessToken
|
||||
d.userID = data.User.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Emby) getItems(ctx context.Context, parentID string) (*listResp, error) {
|
||||
u, err := url.Parse(d.URL + "/Users/" + d.userID + "/Items")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("ParentId", parentID)
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("Fields", "Path,Size,DateCreated,SeriesName,IndexNumber,ParentIndexNumber")
|
||||
q.Set("api_key", d.token)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("emby list failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var data listResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
+25
-2
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
stdpath "path"
|
||||
"strings"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
@@ -51,13 +52,35 @@ func (d *FTP) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]m
|
||||
if err := d.login(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := d.conn.List(encode(dir.GetPath(), d.Encoding))
|
||||
|
||||
path := encode(dir.GetPath(), d.Encoding)
|
||||
|
||||
var entries []*ftp.Entry
|
||||
var err error
|
||||
|
||||
if d.CwdList {
|
||||
origDir, cwdErr := d.conn.CurrentDir()
|
||||
if cwdErr != nil {
|
||||
return nil, cwdErr
|
||||
}
|
||||
if cwdErr = d.conn.ChangeDir(path); cwdErr != nil {
|
||||
return nil, cwdErr
|
||||
}
|
||||
entries, err = d.conn.List("")
|
||||
if restoreErr := d.conn.ChangeDir(origDir); restoreErr != nil {
|
||||
d.conn = nil
|
||||
}
|
||||
} else {
|
||||
entries, err = d.conn.List(path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := make([]model.Obj, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.Name == "." || entry.Name == ".." {
|
||||
if entry.Name == "." || entry.Name == ".." || strings.Contains(entry.Name, "/") {
|
||||
continue
|
||||
}
|
||||
name := decode(entry.Name, d.Encoding)
|
||||
|
||||
@@ -27,6 +27,7 @@ type Addition struct {
|
||||
Encoding string `json:"encoding" required:"true"`
|
||||
Username string `json:"username" required:"true"`
|
||||
Password string `json:"password" required:"true"`
|
||||
CwdList bool `json:"cwd_list" type:"bool" default:"false" help:"enter directory before listing"`
|
||||
driver.RootPath
|
||||
}
|
||||
|
||||
|
||||
@@ -154,15 +154,17 @@ func (d *Github) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
} else {
|
||||
ret := make([]model.Obj, 0, len(obj.Entries))
|
||||
for _, entry := range obj.Entries {
|
||||
if entry.Name != ".gitkeep" {
|
||||
ret = append(ret, entry.toModelObj())
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
ret := make([]model.Obj, 0, len(obj.Entries))
|
||||
for _, entry := range obj.Entries {
|
||||
if entry.Name == ".gitkeep" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, entry.toModelObj())
|
||||
}
|
||||
d.fetchAccurateModifiedTimes(ctx, dir.GetPath(), ret)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (d *Github) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
|
||||
+18
-17
@@ -7,23 +7,24 @@ import (
|
||||
|
||||
type Addition struct {
|
||||
driver.RootPath
|
||||
Token string `json:"token" type:"string" required:"true"`
|
||||
Owner string `json:"owner" type:"string" required:"true"`
|
||||
Repo string `json:"repo" type:"string" required:"true"`
|
||||
Ref string `json:"ref" type:"string" help:"A branch, a tag or a commit SHA, main branch by default."`
|
||||
GitHubProxy string `json:"gh_proxy" type:"string" help:"GitHub proxy, e.g. https://ghproxy.net/raw.githubusercontent.com or https://gh-proxy.com/raw.githubusercontent.com"`
|
||||
GPGPrivateKey string `json:"gpg_private_key" type:"text"`
|
||||
GPGKeyPassphrase string `json:"gpg_key_passphrase" type:"string"`
|
||||
CommitterName string `json:"committer_name" type:"string"`
|
||||
CommitterEmail string `json:"committer_email" type:"string"`
|
||||
AuthorName string `json:"author_name" type:"string"`
|
||||
AuthorEmail string `json:"author_email" type:"string"`
|
||||
MkdirCommitMsg string `json:"mkdir_commit_message" type:"text" default:"{{.UserName}} mkdir {{.ObjPath}}"`
|
||||
DeleteCommitMsg string `json:"delete_commit_message" type:"text" default:"{{.UserName}} remove {{.ObjPath}}"`
|
||||
PutCommitMsg string `json:"put_commit_message" type:"text" default:"{{.UserName}} upload {{.ObjPath}}"`
|
||||
RenameCommitMsg string `json:"rename_commit_message" type:"text" default:"{{.UserName}} rename {{.ObjPath}} to {{.TargetName}}"`
|
||||
CopyCommitMsg string `json:"copy_commit_message" type:"text" default:"{{.UserName}} copy {{.ObjPath}} to {{.TargetPath}}"`
|
||||
MoveCommitMsg string `json:"move_commit_message" type:"text" default:"{{.UserName}} move {{.ObjPath}} to {{.TargetPath}}"`
|
||||
Token string `json:"token" type:"string" required:"true"`
|
||||
Owner string `json:"owner" type:"string" required:"true"`
|
||||
Repo string `json:"repo" type:"string" required:"true"`
|
||||
Ref string `json:"ref" type:"string" help:"A branch, a tag or a commit SHA, main branch by default."`
|
||||
AccurateModifiedTime bool `json:"accurate_modified_time" type:"bool" default:"false" help:"Best-effort accurate modified time for small directory listings. Default disabled. Adds one GitHub GraphQL request and falls back to legacy zero-time values on failure."`
|
||||
GitHubProxy string `json:"gh_proxy" type:"string" help:"GitHub proxy, e.g. https://ghproxy.net/raw.githubusercontent.com or https://gh-proxy.com/raw.githubusercontent.com"`
|
||||
GPGPrivateKey string `json:"gpg_private_key" type:"text"`
|
||||
GPGKeyPassphrase string `json:"gpg_key_passphrase" type:"string"`
|
||||
CommitterName string `json:"committer_name" type:"string"`
|
||||
CommitterEmail string `json:"committer_email" type:"string"`
|
||||
AuthorName string `json:"author_name" type:"string"`
|
||||
AuthorEmail string `json:"author_email" type:"string"`
|
||||
MkdirCommitMsg string `json:"mkdir_commit_message" type:"text" default:"{{.UserName}} mkdir {{.ObjPath}}"`
|
||||
DeleteCommitMsg string `json:"delete_commit_message" type:"text" default:"{{.UserName}} remove {{.ObjPath}}"`
|
||||
PutCommitMsg string `json:"put_commit_message" type:"text" default:"{{.UserName}} upload {{.ObjPath}}"`
|
||||
RenameCommitMsg string `json:"rename_commit_message" type:"text" default:"{{.UserName}} rename {{.ObjPath}} to {{.TargetName}}"`
|
||||
CopyCommitMsg string `json:"copy_commit_message" type:"text" default:"{{.UserName}} copy {{.ObjPath}} to {{.TargetPath}}"`
|
||||
MoveCommitMsg string `json:"move_commit_message" type:"text" default:"{{.UserName}} move {{.ObjPath}} to {{.TargetPath}}"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
mtimeMaxEntries = 200
|
||||
githubGraphQLEndpoint = "https://api.github.com/graphql"
|
||||
)
|
||||
|
||||
var githubZeroTime = time.Unix(0, 0)
|
||||
|
||||
type graphQLHistory struct {
|
||||
Nodes []struct {
|
||||
CommittedDate time.Time `json:"committedDate"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
|
||||
type graphQLMtimeResponse struct {
|
||||
Data struct {
|
||||
Repository struct {
|
||||
Commit map[string]graphQLHistory `json:"commit"`
|
||||
} `json:"repository"`
|
||||
} `json:"data"`
|
||||
Errors []struct{} `json:"errors"`
|
||||
}
|
||||
|
||||
func quoteGraphQLString(value string) string {
|
||||
quoted, _ := json.Marshal(value)
|
||||
return string(quoted)
|
||||
}
|
||||
|
||||
func buildMtimeQuery(owner, repo, ref string, objs []model.Obj) string {
|
||||
histories := make([]string, 0, len(objs))
|
||||
for i, obj := range objs {
|
||||
path := strings.TrimPrefix(obj.GetPath(), "/")
|
||||
histories = append(histories, fmt.Sprintf(`p%d: history(first: 1, path: %s) { nodes { committedDate } }`, i, quoteGraphQLString(path)))
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`query {
|
||||
repository(owner: %s, name: %s) {
|
||||
commit: object(expression: %s) {
|
||||
... on Commit {
|
||||
%s
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
quoteGraphQLString(owner),
|
||||
quoteGraphQLString(repo),
|
||||
quoteGraphQLString(ref+"^{commit}"),
|
||||
strings.Join(histories, "\n\t\t\t\t"),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Github) fetchAccurateModifiedTimes(ctx context.Context, dirPath string, objs []model.Obj) {
|
||||
token := strings.TrimSpace(d.Token)
|
||||
if !d.AccurateModifiedTime || token == "" || len(objs) == 0 || len(objs) > mtimeMaxEntries {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/vnd.github+json").
|
||||
SetHeader("Authorization", "Bearer "+token).
|
||||
SetBody(map[string]string{"query": buildMtimeQuery(d.Owner, d.Repo, d.Ref, objs)}).
|
||||
Post(githubGraphQLEndpoint)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("github accurate mtime failed for %s: transport", dirPath)
|
||||
return
|
||||
}
|
||||
if res.StatusCode() != http.StatusOK {
|
||||
log.Warnf("github accurate mtime failed for %s: http_%d", dirPath, res.StatusCode())
|
||||
return
|
||||
}
|
||||
|
||||
var response graphQLMtimeResponse
|
||||
if err := utils.Json.Unmarshal(res.Body(), &response); err != nil {
|
||||
log.WithError(err).Warnf("github accurate mtime failed for %s: graphql", dirPath)
|
||||
return
|
||||
}
|
||||
if len(response.Errors) > 0 || response.Data.Repository.Commit == nil {
|
||||
log.Warnf("github accurate mtime failed for %s: graphql", dirPath)
|
||||
return
|
||||
}
|
||||
|
||||
for i, obj := range objs {
|
||||
history := response.Data.Repository.Commit[fmt.Sprintf("p%d", i)]
|
||||
if len(history.Nodes) == 0 {
|
||||
continue
|
||||
}
|
||||
if raw, ok := obj.(*model.Object); ok {
|
||||
raw.Modified = history.Nodes[0].CommittedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
odriver "github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func TestDriverInfoIncludesAccurateModifiedTimeDefault(t *testing.T) {
|
||||
info := op.GetDriverInfoMap()["GitHub API"]
|
||||
for _, item := range info.Additional {
|
||||
if item.Name != "accurate_modified_time" {
|
||||
continue
|
||||
}
|
||||
if item.Default != "false" {
|
||||
t.Fatalf("unexpected default: %q", item.Default)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("accurate_modified_time item not registered")
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
func newGithubTestDriver(rt roundTripFunc, token string, enabled bool) *Github {
|
||||
return &Github{
|
||||
Storage: model.Storage{MountPath: "/github-test", CacheExpiration: 10},
|
||||
Addition: Addition{
|
||||
RootPath: odriver.RootPath{RootFolderPath: "/"},
|
||||
Token: token,
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Ref: "main",
|
||||
AccurateModifiedTime: enabled,
|
||||
},
|
||||
client: resty.New().SetTransport(rt),
|
||||
}
|
||||
}
|
||||
|
||||
func newJSONResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func graphQLQueryFromRequest(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read graphql request body: %v", err)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode graphql request body: %v", err)
|
||||
}
|
||||
query := payload["query"]
|
||||
if query == "" {
|
||||
t.Fatalf("graphql request missing query: %s", string(body))
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func newContentsPayload(t *testing.T, entries []Object) string {
|
||||
t.Helper()
|
||||
return mustJSON(t, map[string]any{
|
||||
"type": "dir",
|
||||
"sha": "tree-sha",
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
func newTreePayload(t *testing.T, sha string, trees []TreeObjResp) string {
|
||||
t.Helper()
|
||||
return mustJSON(t, map[string]any{
|
||||
"sha": sha,
|
||||
"truncated": false,
|
||||
"tree": trees,
|
||||
})
|
||||
}
|
||||
|
||||
func newCommitGraphQLPayload(t *testing.T, histories map[string][]string) string {
|
||||
t.Helper()
|
||||
commit := make(map[string]any, len(histories))
|
||||
for alias, dates := range histories {
|
||||
nodes := make([]map[string]string, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
nodes = append(nodes, map[string]string{"committedDate": date})
|
||||
}
|
||||
commit[alias] = map[string]any{"nodes": nodes}
|
||||
}
|
||||
return mustJSON(t, map[string]any{
|
||||
"data": map[string]any{
|
||||
"repository": map[string]any{"commit": commit},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newSequentialEntries(count int) []Object {
|
||||
entries := make([]Object, 0, count)
|
||||
for i := range count {
|
||||
name := fmt.Sprintf("%03d.md", i)
|
||||
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "file", Size: 1})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func mustObject(t *testing.T, obj model.Obj) *model.Object {
|
||||
t.Helper()
|
||||
raw, ok := model.UnwrapObjName(obj).(*model.Object)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected obj type %T", obj)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestListAppliesAccurateModifiedTimeInOneRequest(t *testing.T) {
|
||||
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
|
||||
entries := []Object{
|
||||
{Name: "a.md", Path: "docs/a.md", Type: "file", Size: 1},
|
||||
{Name: ".gitkeep", Path: "docs/.gitkeep", Type: "file"},
|
||||
{Name: `quote " 文.md`, Path: `docs/quote " 文.md`, Type: "file", Size: 1},
|
||||
{Name: "control.md", Path: "docs/control\x01.md", Type: "file", Size: 1},
|
||||
}
|
||||
graphqlCalls := 0
|
||||
var query string
|
||||
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
|
||||
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
|
||||
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
|
||||
graphqlCalls++
|
||||
query = graphQLQueryFromRequest(t, r)
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer token" {
|
||||
t.Fatalf("unexpected authorization header: %q", got)
|
||||
}
|
||||
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
|
||||
"p0": {stamp.Format(time.RFC3339)},
|
||||
"p1": {},
|
||||
"p2": {},
|
||||
})), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}), "token", true)
|
||||
|
||||
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if graphqlCalls != 1 {
|
||||
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
|
||||
}
|
||||
if strings.Count(query, "history(first: 1") != 3 ||
|
||||
!strings.Contains(query, `object(expression: "main^{commit}")`) ||
|
||||
!strings.Contains(query, `p0: history(first: 1, path: "docs/a.md")`) ||
|
||||
!strings.Contains(query, `p1: history(first: 1, path: "docs/quote \" 文.md")`) ||
|
||||
!strings.Contains(query, `p2: history(first: 1, path: "docs/control\u0001.md")`) {
|
||||
t.Fatalf("query should peel the ref and contain all listed paths once:\n%s", query)
|
||||
}
|
||||
if len(objs) != 3 {
|
||||
t.Fatalf("expected three objects after .gitkeep filtering, got %d", len(objs))
|
||||
}
|
||||
if first := mustObject(t, objs[0]); !first.ModTime().Equal(stamp) || !first.CreateTime().Equal(githubZeroTime) {
|
||||
t.Fatalf("unexpected first timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
|
||||
}
|
||||
if second := mustObject(t, objs[1]); !second.ModTime().Equal(githubZeroTime) || !second.CreateTime().Equal(githubZeroTime) {
|
||||
t.Fatalf("unmatched entry should retain legacy timestamps: mod=%v create=%v", second.ModTime(), second.CreateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSkipsAccurateModifiedTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
token string
|
||||
entries int
|
||||
}{
|
||||
{name: "disabled", enabled: false, token: "token", entries: 1},
|
||||
{name: "missing token", enabled: true, token: "", entries: 1},
|
||||
{name: "over entry limit", enabled: true, token: "token", entries: 201},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graphqlCalls := 0
|
||||
payload := newContentsPayload(t, newSequentialEntries(tc.entries))
|
||||
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
|
||||
return newJSONResponse(http.StatusOK, payload), nil
|
||||
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
|
||||
graphqlCalls++
|
||||
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}), tc.token, tc.enabled)
|
||||
|
||||
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if graphqlCalls != 0 {
|
||||
t.Fatalf("expected zero GraphQL requests, got %d", graphqlCalls)
|
||||
}
|
||||
for _, obj := range objs {
|
||||
raw := mustObject(t, obj)
|
||||
if !raw.ModTime().Equal(githubZeroTime) || !raw.CreateTime().Equal(githubZeroTime) {
|
||||
t.Fatalf("legacy timestamps should be preserved: mod=%v create=%v", raw.ModTime(), raw.CreateTime())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFallsBackWhenGraphQLFails(t *testing.T) {
|
||||
graphqlCalls := 0
|
||||
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
|
||||
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(1))), nil
|
||||
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
|
||||
graphqlCalls++
|
||||
return newJSONResponse(http.StatusOK, `{"errors":[{"message":"rate limited"}]}`), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}), "token", true)
|
||||
|
||||
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("GraphQL failure should be best-effort: %v", err)
|
||||
}
|
||||
if graphqlCalls != 1 {
|
||||
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
|
||||
}
|
||||
obj := mustObject(t, objs[0])
|
||||
if !obj.ModTime().Equal(githubZeroTime) || !obj.CreateTime().Equal(githubZeroTime) {
|
||||
t.Fatalf("failed GraphQL request should retain legacy timestamps: mod=%v create=%v", obj.ModTime(), obj.CreateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsesOneGraphQLRequestAtEntryLimit(t *testing.T) {
|
||||
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
|
||||
graphqlCalls := 0
|
||||
var query string
|
||||
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
|
||||
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(200))), nil
|
||||
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
|
||||
graphqlCalls++
|
||||
query = graphQLQueryFromRequest(t, r)
|
||||
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
|
||||
"p199": {stamp.Format(time.RFC3339)},
|
||||
})), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}), "token", true)
|
||||
|
||||
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if graphqlCalls != 1 || strings.Count(query, "history(first: 1") != 200 {
|
||||
t.Fatalf("200 entries should share one request: calls=%d histories=%d", graphqlCalls, strings.Count(query, "history(first: 1"))
|
||||
}
|
||||
if !strings.Contains(query, `p199: history(first: 1, path: "docs/199.md")`) {
|
||||
t.Fatalf("query missing final entry:\n%s", query)
|
||||
}
|
||||
if len(objs) != 200 || !mustObject(t, objs[199]).ModTime().Equal(stamp) {
|
||||
t.Fatalf("unexpected final object timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListKeepsTreeFallbackOnLegacyPath(t *testing.T) {
|
||||
entries := make([]Object, 0, 1000)
|
||||
for i := range 1000 {
|
||||
name := fmt.Sprintf("dir-%d", i)
|
||||
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "dir"})
|
||||
}
|
||||
graphqlCalls := 0
|
||||
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
|
||||
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/git/trees/"):
|
||||
return newJSONResponse(http.StatusOK, newTreePayload(t, "tree-sha", []TreeObjResp{{TreeObjReq: TreeObjReq{Path: "child.md", Mode: "100644", Type: "blob", Sha: "blob-sha"}, Size: 1, URL: "https://example.invalid/blob"}})), nil
|
||||
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
|
||||
graphqlCalls++
|
||||
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}), "token", true)
|
||||
|
||||
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("unexpected tree fallback result length: %d", len(objs))
|
||||
}
|
||||
first := mustObject(t, objs[0])
|
||||
if first.GetPath() != "/child.md" {
|
||||
t.Fatalf("unexpected tree fallback path: %s", first.GetPath())
|
||||
}
|
||||
if !first.ModTime().Equal(githubZeroTime) || !first.CreateTime().Equal(githubZeroTime) {
|
||||
t.Fatalf("tree fallback should preserve legacy timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
|
||||
}
|
||||
if graphqlCalls != 0 {
|
||||
t.Fatalf("tree fallback should skip GraphQL, got %d calls", graphqlCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpListCacheHitDoesNotRepeatGraphQL(t *testing.T) {
|
||||
op.Cache.ClearAll()
|
||||
defer op.Cache.ClearAll()
|
||||
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
|
||||
graphqlCalls := 0
|
||||
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
|
||||
return newJSONResponse(http.StatusOK, newContentsPayload(t, []Object{{Name: "a.md", Path: "a.md", Type: "file", Size: 1}})), nil
|
||||
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
|
||||
graphqlCalls++
|
||||
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{"p0": {stamp.Format(time.RFC3339)}})), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}), "token", true)
|
||||
|
||||
first, err := op.List(context.Background(), drv, "/", model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected first list error: %v", err)
|
||||
}
|
||||
second, err := op.List(context.Background(), drv, "/", model.ListArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected second list error: %v", err)
|
||||
}
|
||||
if len(first) != 1 || len(second) != 1 {
|
||||
t.Fatalf("unexpected cached results: first=%d second=%d", len(first), len(second))
|
||||
}
|
||||
if graphqlCalls != 1 {
|
||||
t.Fatalf("expected one GraphQL call across cached lists, got %d", graphqlCalls)
|
||||
}
|
||||
if !mustObject(t, first[0]).ModTime().Equal(stamp) {
|
||||
t.Fatalf("expected first list to include backfilled modified time, got %v", mustObject(t, first[0]).ModTime())
|
||||
}
|
||||
if !mustObject(t, second[0]).ModTime().Equal(stamp) {
|
||||
t.Fatalf("expected cached list to retain modified time, got %v", mustObject(t, second[0]).ModTime())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
)
|
||||
@@ -35,7 +33,8 @@ func (o *Object) toModelObj() *model.Object {
|
||||
return &model.Object{
|
||||
Name: o.Name,
|
||||
Size: o.Size,
|
||||
Modified: time.Unix(0, 0),
|
||||
Modified: githubZeroTime,
|
||||
Ctime: githubZeroTime,
|
||||
IsFolder: o.Type == "dir",
|
||||
Path: utils.FixAndCleanPath(o.Path),
|
||||
}
|
||||
@@ -69,7 +68,8 @@ func (o *TreeObjResp) toModelObj() *model.Object {
|
||||
return &model.Object{
|
||||
Name: o.Path,
|
||||
Size: o.Size,
|
||||
Modified: time.Unix(0, 0),
|
||||
Modified: githubZeroTime,
|
||||
Ctime: githubZeroTime,
|
||||
IsFolder: o.Type == "tree",
|
||||
Path: utils.FixAndCleanPath(o.Path),
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type GithubReleases struct {
|
||||
@@ -44,18 +45,33 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis
|
||||
for i := range d.points {
|
||||
point := &d.points[i]
|
||||
|
||||
if !d.Addition.ShowAllVersion { // latest
|
||||
point.RequestRelease(d.GetRequest, args.Refresh)
|
||||
if !d.Addition.ShowAllVersion {
|
||||
// latest version mode
|
||||
release, err := d.getLatestRelease(point.Repo)
|
||||
if err != nil {
|
||||
log.Warnf("failed to request release for %s: %v", point.Repo, err)
|
||||
continue
|
||||
}
|
||||
if release == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if point.Point == path { // 与仓库路径相同
|
||||
files = append(files, point.GetLatestRelease()...)
|
||||
if point.Point == path {
|
||||
// 当前目录就是仓库挂载点
|
||||
files = append(files, releaseToFiles(point.Point, release)...)
|
||||
if d.Addition.ShowReadme {
|
||||
files = append(files, point.GetOtherFile(d.GetRequest, args.Refresh)...)
|
||||
other, err := d.fetchRepoFiles(point.Repo)
|
||||
if err == nil {
|
||||
files = append(files, otherFiles(point.Point, other)...)
|
||||
} else {
|
||||
log.Warnf("failed to get other files for %s: %v", point.Repo, err)
|
||||
}
|
||||
}
|
||||
if d.Addition.ShowSourceCode {
|
||||
files = append(files, point.GetSourceCode()...)
|
||||
files = append(files, sourceCodeFiles(point.Point, release)...)
|
||||
}
|
||||
} else if strings.HasPrefix(point.Point, path) { // 仓库目录的父目录
|
||||
} else if strings.HasPrefix(point.Point, path) {
|
||||
// 仓库目录的父目录,需要聚合显示
|
||||
nextDir := GetNextDir(point.Point, path)
|
||||
if nextDir == "" {
|
||||
continue
|
||||
@@ -65,7 +81,7 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis
|
||||
for index := range files {
|
||||
if files[index].GetName() == nextDir {
|
||||
hasSameDir = true
|
||||
files[index].Size += point.GetLatestSize()
|
||||
files[index].Size += releaseSize(release)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -73,23 +89,47 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis
|
||||
files = append(files, File{
|
||||
Path: stdpath.Join(path, nextDir),
|
||||
FileName: nextDir,
|
||||
Size: point.GetLatestSize(),
|
||||
UpdateAt: point.Release.PublishedAt,
|
||||
CreateAt: point.Release.CreatedAt,
|
||||
Size: releaseSize(release),
|
||||
UpdateAt: release.PublishedAt,
|
||||
CreateAt: release.CreatedAt,
|
||||
Type: "dir",
|
||||
Url: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
} else { // all version
|
||||
point.RequestReleases(d.GetRequest, args.Refresh)
|
||||
|
||||
if point.Point == path { // 与仓库路径相同
|
||||
files = append(files, point.GetAllVersion()...)
|
||||
if d.Addition.ShowReadme {
|
||||
files = append(files, point.GetOtherFile(d.GetRequest, args.Refresh)...)
|
||||
} else {
|
||||
// all versions mode
|
||||
releases, err := d.getAllReleases(point.Repo)
|
||||
if err != nil {
|
||||
log.Warnf("failed to request releases for %s: %v", point.Repo, err)
|
||||
continue
|
||||
}
|
||||
if len(releases) == 0 {
|
||||
// no releases but may still have repo files (e.g. README)
|
||||
if point.Point == path && d.Addition.ShowReadme {
|
||||
other, err := d.fetchRepoFiles(point.Repo)
|
||||
if err == nil {
|
||||
files = append(files, otherFiles(point.Point, other)...)
|
||||
} else {
|
||||
log.Warnf("failed to get other files for %s: %v", point.Repo, err)
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(point.Point, path) { // 仓库目录的父目录
|
||||
continue
|
||||
}
|
||||
|
||||
if point.Point == path {
|
||||
// 当前目录就是仓库挂载点
|
||||
files = append(files, releasesToVersionDirs(point.Point, releases)...)
|
||||
if d.Addition.ShowReadme {
|
||||
other, err := d.fetchRepoFiles(point.Repo)
|
||||
if err == nil {
|
||||
files = append(files, otherFiles(point.Point, other)...)
|
||||
} else {
|
||||
log.Warnf("failed to get other files for %s: %v", point.Repo, err)
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(point.Point, path) {
|
||||
// 仓库目录的父目录
|
||||
nextDir := GetNextDir(point.Point, path)
|
||||
if nextDir == "" {
|
||||
continue
|
||||
@@ -99,7 +139,7 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis
|
||||
for index := range files {
|
||||
if files[index].GetName() == nextDir {
|
||||
hasSameDir = true
|
||||
files[index].Size += point.GetAllVersionSize()
|
||||
files[index].Size += releasesTotalSize(releases)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -107,23 +147,24 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis
|
||||
files = append(files, File{
|
||||
FileName: nextDir,
|
||||
Path: stdpath.Join(path, nextDir),
|
||||
Size: point.GetAllVersionSize(),
|
||||
UpdateAt: (*point.Releases)[0].PublishedAt,
|
||||
CreateAt: (*point.Releases)[0].CreatedAt,
|
||||
Size: releasesTotalSize(releases),
|
||||
UpdateAt: releases[0].PublishedAt,
|
||||
CreateAt: releases[0].CreatedAt,
|
||||
Type: "dir",
|
||||
Url: "",
|
||||
})
|
||||
}
|
||||
} else if strings.HasPrefix(path, point.Point) { // 仓库目录的子目录
|
||||
} else if strings.HasPrefix(path, point.Point) {
|
||||
// 仓库目录的子目录(某个版本)
|
||||
tagName := GetNextDir(path, point.Point)
|
||||
if tagName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, point.GetReleaseByTagName(tagName)...)
|
||||
files = append(files, releaseAssetsByTag(point.Point, tagName, releases)...)
|
||||
|
||||
if d.Addition.ShowSourceCode {
|
||||
files = append(files, point.GetSourceCodeByTagName(tagName)...)
|
||||
files = append(files, sourceCodeFilesByTag(point.Point, releases, tagName)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ type Addition struct {
|
||||
Token string `json:"token" type:"string" required:"false" help:"GitHub token, if you want to access private repositories or increase the rate limit"`
|
||||
ShowSourceCode bool `json:"show_source_code" type:"bool" default:"false" help:"show Source code (zip/tar.gz)"`
|
||||
ShowAllVersion bool `json:"show_all_version" type:"bool" default:"false" help:"show all versions"`
|
||||
GitHubProxy string `json:"gh_proxy" type:"string" default:"" help:"GitHub proxy, e.g. https://ghproxy.net/github.com or https://gh-proxy.com/github.com "`
|
||||
PerPage int `json:"per_page" type:"number" default:"30" help:"releases per page (max 100), only works when show all versions"`
|
||||
MaxPage int `json:"max_page" type:"number" default:"0" help:"max pages to fetch (0 = unlimited), only works when show all versions"`
|
||||
GitHubProxy string `json:"gh_proxy" type:"string" default:"" help:"GitHub proxy, e.g. https://ghproxy.net/https://github.com or https://gh-proxy.com/https://github.com"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
|
||||
@@ -1,55 +1,28 @@
|
||||
package github_releases
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// MountPoint 表示一个仓库挂载点
|
||||
type MountPoint struct {
|
||||
Point string // 挂载点
|
||||
Repo string // 仓库名 owner/repo
|
||||
Release *Release // Release 指针 latest
|
||||
Releases *[]Release // []Release 指针
|
||||
OtherFile *[]FileInfo // 仓库根目录下的其他文件
|
||||
Point string // 挂载点路径
|
||||
Repo string // 仓库名 owner/repo
|
||||
}
|
||||
|
||||
// 请求最新版本
|
||||
func (m *MountPoint) RequestRelease(get func(url string) (*resty.Response, error), refresh bool) {
|
||||
if m.Repo == "" {
|
||||
return
|
||||
// Release 转为 File 列表
|
||||
func releaseToFiles(point string, release *Release) []File {
|
||||
if release == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.Release == nil || refresh {
|
||||
resp, _ := get("https://api.github.com/repos/" + m.Repo + "/releases/latest")
|
||||
m.Release = new(Release)
|
||||
json.Unmarshal(resp.Body(), m.Release)
|
||||
}
|
||||
}
|
||||
|
||||
// 请求所有版本
|
||||
func (m *MountPoint) RequestReleases(get func(url string) (*resty.Response, error), refresh bool) {
|
||||
if m.Repo == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if m.Releases == nil || refresh {
|
||||
resp, _ := get("https://api.github.com/repos/" + m.Repo + "/releases")
|
||||
m.Releases = new([]Release)
|
||||
json.Unmarshal(resp.Body(), m.Releases)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最新版本
|
||||
func (m *MountPoint) GetLatestRelease() []File {
|
||||
files := make([]File, 0, len(m.Release.Assets))
|
||||
for _, asset := range m.Release.Assets {
|
||||
files := make([]File, 0, len(release.Assets))
|
||||
for _, asset := range release.Assets {
|
||||
files = append(files, File{
|
||||
Path: path.Join(m.Point, asset.Name),
|
||||
Path: path.Join(point, asset.Name),
|
||||
FileName: asset.Name,
|
||||
Size: asset.Size,
|
||||
Type: "file",
|
||||
@@ -61,44 +34,43 @@ func (m *MountPoint) GetLatestRelease() []File {
|
||||
return files
|
||||
}
|
||||
|
||||
// 获取最新版本大小
|
||||
func (m *MountPoint) GetLatestSize() int64 {
|
||||
// 计算 release 的 asset 总大小
|
||||
func releaseSize(release *Release) int64 {
|
||||
if release == nil {
|
||||
return 0
|
||||
}
|
||||
size := int64(0)
|
||||
for _, asset := range m.Release.Assets {
|
||||
for _, asset := range release.Assets {
|
||||
size += asset.Size
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// 获取所有版本
|
||||
func (m *MountPoint) GetAllVersion() []File {
|
||||
files := make([]File, 0)
|
||||
for _, release := range *m.Releases {
|
||||
file := File{
|
||||
Path: path.Join(m.Point, release.TagName),
|
||||
// Releases 列表转为版本目录 File 列表
|
||||
func releasesToVersionDirs(point string, releases []Release) []File {
|
||||
files := make([]File, 0, len(releases))
|
||||
for _, release := range releases {
|
||||
files = append(files, File{
|
||||
Path: path.Join(point, release.TagName),
|
||||
FileName: release.TagName,
|
||||
Size: m.GetSizeByTagName(release.TagName),
|
||||
Size: releaseSize(&release),
|
||||
Type: "dir",
|
||||
UpdateAt: release.PublishedAt,
|
||||
CreateAt: release.CreatedAt,
|
||||
Url: release.HtmlUrl,
|
||||
}
|
||||
for _, asset := range release.Assets {
|
||||
file.Size += asset.Size
|
||||
}
|
||||
files = append(files, file)
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// 根据版本号获取版本
|
||||
func (m *MountPoint) GetReleaseByTagName(tagName string) []File {
|
||||
for _, item := range *m.Releases {
|
||||
// 根据 tagName 查找 release 的 asset 文件列表
|
||||
func releaseAssetsByTag(point, tagName string, releases []Release) []File {
|
||||
for _, item := range releases {
|
||||
if item.TagName == tagName {
|
||||
files := make([]File, 0)
|
||||
files := make([]File, 0, len(item.Assets))
|
||||
for _, asset := range item.Assets {
|
||||
files = append(files, File{
|
||||
Path: path.Join(m.Point, tagName, asset.Name),
|
||||
Path: path.Join(point, tagName, asset.Name),
|
||||
FileName: asset.Name,
|
||||
Size: asset.Size,
|
||||
Type: "file",
|
||||
@@ -113,104 +85,74 @@ func (m *MountPoint) GetReleaseByTagName(tagName string) []File {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 根据版本号获取版本大小
|
||||
func (m *MountPoint) GetSizeByTagName(tagName string) int64 {
|
||||
if m.Releases == nil {
|
||||
return 0
|
||||
}
|
||||
for _, item := range *m.Releases {
|
||||
// 根据 tagName 计算 asset 总大小
|
||||
func releasesSizeByTag(releases []Release, tagName string) int64 {
|
||||
for _, item := range releases {
|
||||
if item.TagName == tagName {
|
||||
size := int64(0)
|
||||
for _, asset := range item.Assets {
|
||||
size += asset.Size
|
||||
}
|
||||
return size
|
||||
return releaseSize(&item)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 获取所有版本大小
|
||||
func (m *MountPoint) GetAllVersionSize() int64 {
|
||||
if m.Releases == nil {
|
||||
return 0
|
||||
}
|
||||
// 计算所有 releases 的 asset 总大小
|
||||
func releasesTotalSize(releases []Release) int64 {
|
||||
size := int64(0)
|
||||
for _, release := range *m.Releases {
|
||||
for _, asset := range release.Assets {
|
||||
size += asset.Size
|
||||
}
|
||||
for _, release := range releases {
|
||||
size += releaseSize(&release)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func (m *MountPoint) GetSourceCode() []File {
|
||||
files := make([]File, 0)
|
||||
|
||||
// 无法获取文件大小,此处设为 1
|
||||
files = append(files, File{
|
||||
Path: path.Join(m.Point, "Source code (zip)"),
|
||||
FileName: "Source code (zip)",
|
||||
Size: 1,
|
||||
Type: "file",
|
||||
UpdateAt: m.Release.CreatedAt,
|
||||
CreateAt: m.Release.CreatedAt,
|
||||
Url: m.Release.ZipballUrl,
|
||||
})
|
||||
files = append(files, File{
|
||||
Path: path.Join(m.Point, "Source code (tar.gz)"),
|
||||
FileName: "Source code (tar.gz)",
|
||||
Size: 1,
|
||||
Type: "file",
|
||||
UpdateAt: m.Release.CreatedAt,
|
||||
CreateAt: m.Release.CreatedAt,
|
||||
Url: m.Release.TarballUrl,
|
||||
})
|
||||
|
||||
return files
|
||||
// Source code 文件
|
||||
func sourceCodeFiles(point string, release *Release) []File {
|
||||
if release == nil {
|
||||
return nil
|
||||
}
|
||||
return []File{
|
||||
{
|
||||
Path: path.Join(point, "Source code (zip)"),
|
||||
FileName: "Source code (zip)",
|
||||
Size: 1,
|
||||
Type: "file",
|
||||
UpdateAt: release.CreatedAt,
|
||||
CreateAt: release.CreatedAt,
|
||||
Url: release.ZipballUrl,
|
||||
},
|
||||
{
|
||||
Path: path.Join(point, "Source code (tar.gz)"),
|
||||
FileName: "Source code (tar.gz)",
|
||||
Size: 1,
|
||||
Type: "file",
|
||||
UpdateAt: release.CreatedAt,
|
||||
CreateAt: release.CreatedAt,
|
||||
Url: release.TarballUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MountPoint) GetSourceCodeByTagName(tagName string) []File {
|
||||
for _, item := range *m.Releases {
|
||||
// 根据 tagName 获取 Source Code 文件
|
||||
func sourceCodeFilesByTag(point string, releases []Release, tagName string) []File {
|
||||
for _, item := range releases {
|
||||
if item.TagName == tagName {
|
||||
files := make([]File, 0)
|
||||
files = append(files, File{
|
||||
Path: path.Join(m.Point, "Source code (zip)"),
|
||||
FileName: "Source code (zip)",
|
||||
Size: 1,
|
||||
Type: "file",
|
||||
UpdateAt: item.CreatedAt,
|
||||
CreateAt: item.CreatedAt,
|
||||
Url: item.ZipballUrl,
|
||||
})
|
||||
files = append(files, File{
|
||||
Path: path.Join(m.Point, "Source code (tar.gz)"),
|
||||
FileName: "Source code (tar.gz)",
|
||||
Size: 1,
|
||||
Type: "file",
|
||||
UpdateAt: item.CreatedAt,
|
||||
CreateAt: item.CreatedAt,
|
||||
Url: item.TarballUrl,
|
||||
})
|
||||
return files
|
||||
return sourceCodeFiles(point, &item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MountPoint) GetOtherFile(get func(url string) (*resty.Response, error), refresh bool) []File {
|
||||
if m.OtherFile == nil || refresh {
|
||||
resp, _ := get("https://api.github.com/repos/" + m.Repo + "/contents")
|
||||
m.OtherFile = new([]FileInfo)
|
||||
json.Unmarshal(resp.Body(), m.OtherFile)
|
||||
}
|
||||
|
||||
// 仓库根目录下的 README/LICENSE 文件
|
||||
func otherFiles(point string, fileInfos []FileInfo) []File {
|
||||
files := make([]File, 0)
|
||||
defaultTime := "1970-01-01T00:00:00Z"
|
||||
for _, file := range *m.OtherFile {
|
||||
if strings.HasSuffix(file.Name, ".md") || strings.HasPrefix(file.Name, "LICENSE") {
|
||||
for _, file := range fileInfos {
|
||||
if file.Type == "dir" {
|
||||
continue
|
||||
}
|
||||
name := file.Name
|
||||
if strings.EqualFold(name, "README.md") || strings.HasPrefix(name, "LICENSE") {
|
||||
files = append(files, File{
|
||||
Path: path.Join(m.Point, file.Name),
|
||||
Path: path.Join(point, file.Name),
|
||||
FileName: file.Name,
|
||||
Size: file.Size,
|
||||
Type: "file",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package github_releases
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/go-resty/resty/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 发送 GET 请求
|
||||
@@ -23,7 +22,7 @@ func (d *GithubReleases) GetRequest(url string) (*resty.Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode() != 200 {
|
||||
log.Warn("failed to get request: ", res.StatusCode(), res.String())
|
||||
return nil, fmt.Errorf("github api error: status %d", res.StatusCode())
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -50,10 +49,8 @@ func (d *GithubReleases) ParseRepos(text string) ([]MountPoint, error) {
|
||||
}
|
||||
|
||||
points = append(points, MountPoint{
|
||||
Point: path,
|
||||
Repo: repo,
|
||||
Release: nil,
|
||||
Releases: nil,
|
||||
Point: path,
|
||||
Repo: repo,
|
||||
})
|
||||
}
|
||||
d.points = points
|
||||
@@ -77,9 +74,79 @@ func GetNextDir(wholePath string, basePath string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 判断当前目录是否是目标目录的祖先目录
|
||||
func IsAncestorDir(parentDir string, targetDir string) bool {
|
||||
absTargetDir, _ := filepath.Abs(targetDir)
|
||||
absParentDir, _ := filepath.Abs(parentDir)
|
||||
return strings.HasPrefix(absTargetDir, absParentDir)
|
||||
// getLatestRelease 获取最新 release
|
||||
func (d *GithubReleases) getLatestRelease(repo string) (*Release, error) {
|
||||
resp, err := d.GetRequest("https://api.github.com/repos/" + repo + "/releases/latest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release := new(Release)
|
||||
if err := json.Unmarshal(resp.Body(), release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// getAllReleases 获取所有 releases(支持自动翻页)
|
||||
func (d *GithubReleases) getAllReleases(repo string) ([]Release, error) {
|
||||
perPage := d.Addition.PerPage
|
||||
if perPage < 1 {
|
||||
perPage = 30
|
||||
} else if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
maxPage := d.Addition.MaxPage
|
||||
if maxPage < 0 {
|
||||
maxPage = 0
|
||||
}
|
||||
|
||||
allReleases := make([]Release, 0)
|
||||
page := 1
|
||||
|
||||
for {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d&page=%d", repo, perPage, page)
|
||||
resp, err := d.GetRequest(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releases := make([]Release, 0)
|
||||
if err := json.Unmarshal(resp.Body(), &releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(releases) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
allReleases = append(allReleases, releases...)
|
||||
|
||||
// 达到最大页数限制
|
||||
if maxPage > 0 && page >= maxPage {
|
||||
break
|
||||
}
|
||||
|
||||
// 如果返回数量小于 perPage,说明是最后一页
|
||||
if len(releases) < perPage {
|
||||
break
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return allReleases, nil
|
||||
}
|
||||
|
||||
// fetchRepoFiles 获取仓库根目录文件列表
|
||||
func (d *GithubReleases) fetchRepoFiles(repo string) ([]FileInfo, error) {
|
||||
resp, err := d.GetRequest("https://api.github.com/repos/" + repo + "/contents")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]FileInfo, 0)
|
||||
if err := json.Unmarshal(resp.Body(), &files); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
package guangyapan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/go-resty/resty/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
accountBaseURL = "https://account.guangyapan.com"
|
||||
apiBaseURL = "https://api.guangyapan.com"
|
||||
)
|
||||
|
||||
type GuangYaPan struct {
|
||||
model.Storage
|
||||
Addition
|
||||
|
||||
accountClient *resty.Client
|
||||
apiClient *resty.Client
|
||||
|
||||
resolvedRootFolderID string
|
||||
rootFolderResolved bool
|
||||
|
||||
// refreshMu protects concurrent access to AccessToken/RefreshToken during refresh.
|
||||
refreshMu sync.Mutex
|
||||
// statusTimer tracks delayed status updates for cancellation on Drop.
|
||||
statusTimer *time.Timer
|
||||
|
||||
// apiRateLimit throttles requests per API endpoint so that batch operations
|
||||
// (e.g. copying many files cross-storage) don't flood the upstream API.
|
||||
apiRateLimit sync.Map
|
||||
}
|
||||
|
||||
// apiRateInterval is the minimum gap between two requests to the same endpoint.
|
||||
const apiRateInterval = 500 * time.Millisecond
|
||||
|
||||
func (d *GuangYaPan) Config() driver.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) GetAddition() driver.Additional {
|
||||
return &d.Addition
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Init(ctx context.Context) error {
|
||||
d.ClientID = strings.TrimSpace(d.ClientID)
|
||||
if d.ClientID == "" {
|
||||
return errors.New("client_id is required, please provide a valid client_id")
|
||||
}
|
||||
d.DeviceID = normalizeDeviceID(d.DeviceID)
|
||||
if d.DeviceID == "" {
|
||||
d.DeviceID = randomDeviceID()
|
||||
}
|
||||
deviceSign := strings.TrimSpace(d.DeviceSign)
|
||||
if deviceSign == "" {
|
||||
deviceSign = "wdi10." + d.DeviceID
|
||||
}
|
||||
if d.PageSize <= 0 {
|
||||
d.PageSize = 100
|
||||
}
|
||||
if d.OrderBy < 0 {
|
||||
d.OrderBy = 3
|
||||
}
|
||||
if d.SortType != 0 && d.SortType != 1 {
|
||||
d.SortType = 1
|
||||
}
|
||||
|
||||
d.RootPath = strings.TrimSpace(d.RootPath)
|
||||
d.AccessToken = strings.TrimSpace(d.AccessToken)
|
||||
d.RefreshToken = strings.TrimSpace(d.RefreshToken)
|
||||
d.PhoneNumber = strings.TrimSpace(d.PhoneNumber)
|
||||
d.VerifyCode = strings.TrimSpace(d.VerifyCode)
|
||||
d.CaptchaToken = strings.TrimSpace(d.CaptchaToken)
|
||||
d.VerificationID = strings.TrimSpace(d.VerificationID)
|
||||
d.resolvedRootFolderID = ""
|
||||
d.rootFolderResolved = false
|
||||
|
||||
d.accountClient = base.NewRestyClient().
|
||||
SetBaseURL(accountBaseURL).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("X-Device-Model", "chrome%2F147.0.0.0").
|
||||
SetHeader("X-Device-Name", "PC-Chrome").
|
||||
SetHeader("X-Device-Sign", deviceSign).
|
||||
SetHeader("X-Net-Work-Type", "NONE").
|
||||
SetHeader("X-OS-Version", "MacIntel").
|
||||
SetHeader("X-Platform-Version", "1").
|
||||
SetHeader("X-Protocol-Version", "301").
|
||||
SetHeader("X-Provider-Name", "NONE").
|
||||
SetHeader("X-SDK-Version", "9.0.2").
|
||||
SetHeader("X-Client-Id", d.ClientID).
|
||||
SetHeader("X-Client-Version", "0.0.1").
|
||||
SetHeader("X-Device-Id", d.DeviceID)
|
||||
|
||||
d.apiClient = base.NewRestyClient().
|
||||
SetBaseURL(apiBaseURL).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Did", d.DeviceID).
|
||||
SetHeader("Dt", "4")
|
||||
|
||||
// Priority: access_token -> refresh_token -> sms login.
|
||||
if d.AccessToken != "" {
|
||||
if err := d.validateToken(ctx); err == nil {
|
||||
return d.prepareRootFolder(ctx)
|
||||
}
|
||||
d.AccessToken = ""
|
||||
}
|
||||
if d.RefreshToken != "" {
|
||||
if err := d.refreshToken(ctx); err == nil {
|
||||
if err2 := d.validateToken(ctx); err2 == nil {
|
||||
return d.prepareRootFolder(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Two-stage SMS flow:
|
||||
// 1) phone only + send_code=true: send code and cache verification_id (do not fail init).
|
||||
// 2) phone + verify_code: complete login and save tokens.
|
||||
if d.PhoneNumber != "" {
|
||||
if d.canSMSLogin() {
|
||||
if err := d.loginBySMSCode(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.validateToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.prepareRootFolder(ctx)
|
||||
}
|
||||
if d.SendCode {
|
||||
d.setTempStatus("SMS sending in progress...")
|
||||
if err := d.prepareSMSCode(ctx); err != nil {
|
||||
d.setTempStatus(fmt.Sprintf("SMS send failed: %v. Please check captcha/meta and set send_code=true to retry.", err))
|
||||
log.Warnf("guangyapan: prepare sms code failed: %v", err)
|
||||
} else {
|
||||
d.setTempStatus("SMS sent successfully. Please fill verify_code and save to complete login.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("login failed: provide a valid access_token, or refresh_token, or phone_number + verify_code + captcha_token")
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Drop(ctx context.Context) error {
|
||||
if d.statusTimer != nil {
|
||||
d.statusTimer.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) GetRoot(ctx context.Context) (model.Obj, error) {
|
||||
rootID, err := d.getRootFolderID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.Object{
|
||||
ID: rootID,
|
||||
Path: "/",
|
||||
Name: "root",
|
||||
Size: 0,
|
||||
Modified: d.Modified,
|
||||
IsFolder: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentID := dir.GetID()
|
||||
|
||||
const maxPage = 10000
|
||||
res := make([]model.Obj, 0, d.PageSize)
|
||||
for page := 0; page < maxPage; page++ {
|
||||
var resp listResp
|
||||
body := map[string]any{
|
||||
"parentId": parentID,
|
||||
"page": page,
|
||||
"pageSize": d.PageSize,
|
||||
"orderBy": d.OrderBy,
|
||||
"sortType": d.SortType,
|
||||
}
|
||||
if err := d.postAPI(ctx, "/userres/v1/file/get_file_list", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range resp.Data.List {
|
||||
res = append(res, &model.Object{
|
||||
ID: item.FileID,
|
||||
Path: parentID,
|
||||
Name: item.FileName,
|
||||
Size: item.FileSize,
|
||||
Modified: unixOrZero(item.UTime),
|
||||
Ctime: unixOrZero(item.CTime),
|
||||
IsFolder: item.ResType == 2,
|
||||
})
|
||||
}
|
||||
if len(resp.Data.List) < d.PageSize {
|
||||
break
|
||||
}
|
||||
if resp.Data.Total > 0 && len(res) >= resp.Data.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if file.IsDir() {
|
||||
return nil, errs.NotFile
|
||||
}
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp downloadResp
|
||||
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_download_url", map[string]any{
|
||||
"fileId": file.GetID(),
|
||||
}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(resp.Data.SignedURL)
|
||||
if url == "" {
|
||||
url = strings.TrimSpace(resp.Data.DownloadURL)
|
||||
}
|
||||
if url == "" {
|
||||
return nil, errors.New("empty download url")
|
||||
}
|
||||
return &model.Link{URL: url}, nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(dirName)
|
||||
if name == "" {
|
||||
return errors.New("dir name is empty")
|
||||
}
|
||||
|
||||
parentID := parentDir.GetID()
|
||||
|
||||
var out createDirResp
|
||||
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/create_dir", map[string]any{
|
||||
"parentId": parentID,
|
||||
"dirName": name,
|
||||
}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isSuccessMsg(out.Msg) {
|
||||
return fmt.Errorf("make dir failed: %s", strings.TrimSpace(out.Msg))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := strings.TrimSpace(srcObj.GetID())
|
||||
if fileID == "" {
|
||||
return errors.New("file id is empty")
|
||||
}
|
||||
name := strings.TrimSpace(newName)
|
||||
if name == "" {
|
||||
return errors.New("new name is empty")
|
||||
}
|
||||
|
||||
var out commonResp
|
||||
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/rename", map[string]any{
|
||||
"fileId": fileID,
|
||||
"newName": name,
|
||||
}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isSuccessMsg(out.Msg) {
|
||||
return fmt.Errorf("rename failed: %s", strings.TrimSpace(out.Msg))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Remove(ctx context.Context, obj model.Obj) error {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := strings.TrimSpace(obj.GetID())
|
||||
if fileID == "" {
|
||||
return errors.New("file id is empty")
|
||||
}
|
||||
|
||||
var del taskResp
|
||||
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/delete_file", map[string]any{
|
||||
"fileIds": []string{fileID},
|
||||
}, &del); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isSuccessMsg(del.Msg) {
|
||||
return fmt.Errorf("delete failed: %s", strings.TrimSpace(del.Msg))
|
||||
}
|
||||
|
||||
taskID := strings.TrimSpace(del.Data.TaskID)
|
||||
if taskID == "" {
|
||||
// Some backends may apply deletion synchronously.
|
||||
return nil
|
||||
}
|
||||
return d.waitTaskDone(ctx, taskID)
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := strings.TrimSpace(srcObj.GetID())
|
||||
if fileID == "" {
|
||||
return errors.New("file id is empty")
|
||||
}
|
||||
parentID := dstDir.GetID()
|
||||
|
||||
var out taskResp
|
||||
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/move_file", map[string]any{
|
||||
"fileIds": []string{fileID},
|
||||
"parentId": parentID,
|
||||
}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isSuccessMsg(out.Msg) {
|
||||
return fmt.Errorf("move failed: %s", strings.TrimSpace(out.Msg))
|
||||
}
|
||||
taskID := strings.TrimSpace(out.Data.TaskID)
|
||||
if taskID == "" {
|
||||
return nil
|
||||
}
|
||||
return d.waitTaskDone(ctx, taskID)
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := strings.TrimSpace(srcObj.GetID())
|
||||
if fileID == "" {
|
||||
return errors.New("file id is empty")
|
||||
}
|
||||
parentID := dstDir.GetID()
|
||||
|
||||
var out taskResp
|
||||
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/copy_file", map[string]any{
|
||||
"fileIds": []string{fileID},
|
||||
"parentId": parentID,
|
||||
}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isSuccessMsg(out.Msg) {
|
||||
return fmt.Errorf("copy failed: %s", strings.TrimSpace(out.Msg))
|
||||
}
|
||||
taskID := strings.TrimSpace(out.Data.TaskID)
|
||||
if taskID == "" {
|
||||
return nil
|
||||
}
|
||||
return d.waitTaskDone(ctx, taskID)
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file == nil {
|
||||
return nil, errors.New("file is nil")
|
||||
}
|
||||
if file.GetSize() < 0 {
|
||||
return nil, errors.New("invalid file size")
|
||||
}
|
||||
name := strings.TrimSpace(file.GetName())
|
||||
if name == "" {
|
||||
return nil, errors.New("file name is empty")
|
||||
}
|
||||
|
||||
parentID := dstDir.GetID()
|
||||
|
||||
// 优先秒传:先计算文件 MD5,后端命中相同文件时直接秒传完成,无需真实上传。
|
||||
md5sum, err := d.fileMD5(file, up)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, code, err := d.getUploadToken(ctx, parentID, name, file.GetSize(), md5sum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskID := strings.TrimSpace(token.TaskID)
|
||||
// code == 156 (instant upload) or AlreadyDone mean the backend has already
|
||||
// finished/imported the file; there is no OSS upload to perform.
|
||||
if code == 156 || token.AlreadyDone {
|
||||
if taskID == "" {
|
||||
return nil, errors.New("instant upload returns empty task id")
|
||||
}
|
||||
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if token.ObjectPath == "" || token.BucketName == "" || token.EndPoint == "" || token.AccessKeyID == "" || token.SecretAccessKey == "" {
|
||||
return nil, errors.New("upload token is incomplete")
|
||||
}
|
||||
|
||||
ossEndpoint := normalizeOSSEndpoint(token.EndPoint, token.BucketName)
|
||||
client, err := oss.New(ossEndpoint, token.AccessKeyID, token.SecretAccessKey, oss.SecurityToken(token.SessionToken))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create oss client failed: %w", err)
|
||||
}
|
||||
bucket, err := client.Bucket(token.BucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create oss bucket failed: %w", err)
|
||||
}
|
||||
|
||||
if file.GetSize() == 0 {
|
||||
if err := bucket.PutObject(token.ObjectPath, strings.NewReader("")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := d.multipartUploadToOSS(ctx, bucket, token.ObjectPath, file, up); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if taskID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp assetsInfoResp
|
||||
if err := d.postAPI(ctx, "/nd.bizassets.s/v1/get_assets", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.IsSuccess() && resp.Data.TotalSpaceSize > 0 {
|
||||
return &model.StorageDetails{
|
||||
DiskUsage: model.DiskUsage{
|
||||
TotalSpace: resp.Data.TotalSpaceSize,
|
||||
UsedSpace: resp.Data.UsedSpaceSize,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("failed to get storage details")
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) getRootFolderID(ctx context.Context) (string, error) {
|
||||
if d.rootFolderResolved {
|
||||
return d.resolvedRootFolderID, nil
|
||||
}
|
||||
if err := d.ensureAccessToken(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := d.prepareRootFolder(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return d.resolvedRootFolderID, nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) prepareRootFolder(ctx context.Context) error {
|
||||
rootID, err := d.resolveConfiguredRootFolderID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.resolvedRootFolderID = rootID
|
||||
d.rootFolderResolved = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) resolveConfiguredRootFolderID(ctx context.Context) (string, error) {
|
||||
root := strings.TrimSpace(d.RootPath)
|
||||
if root == "" {
|
||||
return "", nil
|
||||
}
|
||||
return d.resolveFolderPath(ctx, root)
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) resolveFolderPath(ctx context.Context, rootPath string) (string, error) {
|
||||
cleanPath := strings.Trim(strings.ReplaceAll(strings.TrimSpace(rootPath), "\\", "/"), "/")
|
||||
if cleanPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parentID := ""
|
||||
for _, name := range strings.Split(cleanPath, "/") {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
childID, err := d.findChildFolderID(ctx, parentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parentID = childID
|
||||
}
|
||||
return parentID, nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) findChildFolderID(ctx context.Context, parentID, name string) (string, error) {
|
||||
pageSize := d.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
const maxPage = 10000
|
||||
seen := 0
|
||||
for page := 0; page < maxPage; page++ {
|
||||
var resp listResp
|
||||
body := map[string]any{
|
||||
"parentId": parentID,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"orderBy": d.OrderBy,
|
||||
"sortType": d.SortType,
|
||||
}
|
||||
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_file_list", body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, item := range resp.Data.List {
|
||||
seen++
|
||||
if item.ResType == 2 && item.FileName == name {
|
||||
return item.FileID, nil
|
||||
}
|
||||
}
|
||||
if len(resp.Data.List) < pageSize {
|
||||
break
|
||||
}
|
||||
if resp.Data.Total > 0 && seen >= resp.Data.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parentID == "" {
|
||||
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under /", name)
|
||||
}
|
||||
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under parent %s", name, parentID)
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) ensureAccessToken(ctx context.Context) error {
|
||||
if strings.TrimSpace(d.AccessToken) != "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(d.RefreshToken) == "" {
|
||||
return errors.New("not logged in, please re-init storage")
|
||||
}
|
||||
return d.refreshToken(ctx)
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) validateToken(ctx context.Context) error {
|
||||
var me userMeResp
|
||||
resp, err := d.accountClient.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+d.AccessToken).
|
||||
SetResult(&me).
|
||||
Get("/v1/user/me")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.IsError() {
|
||||
return fmt.Errorf("validate token failed: status=%d body=%s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
if strings.TrimSpace(me.Sub) == "" {
|
||||
return errors.New("validate token failed: empty user sub")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) refreshToken(ctx context.Context) error {
|
||||
if strings.TrimSpace(d.RefreshToken) == "" {
|
||||
return errors.New("refresh_token is empty")
|
||||
}
|
||||
|
||||
d.refreshMu.Lock()
|
||||
defer d.refreshMu.Unlock()
|
||||
|
||||
// Double-check after acquiring lock (may have been refreshed by another goroutine)
|
||||
if strings.TrimSpace(d.AccessToken) != "" {
|
||||
if err := d.validateToken(ctx); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var out tokenResp
|
||||
resp, err := d.accountClient.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"client_id": d.ClientID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": d.RefreshToken,
|
||||
}).
|
||||
SetResult(&out).
|
||||
Post("/v1/auth/token")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
|
||||
errMsg := strings.TrimSpace(out.ErrorDesc)
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(out.Error)
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(resp.String())
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = fmt.Sprintf("status=%d", resp.StatusCode())
|
||||
}
|
||||
return fmt.Errorf("refresh token failed: %s", errMsg)
|
||||
}
|
||||
|
||||
d.AccessToken = strings.TrimSpace(out.AccessToken)
|
||||
if strings.TrimSpace(out.RefreshToken) != "" {
|
||||
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
|
||||
}
|
||||
op.MustSaveDriverStorage(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) canSMSLogin() bool {
|
||||
return d.PhoneNumber != "" && d.VerifyCode != ""
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) loginBySMSCode(ctx context.Context) error {
|
||||
verificationID := strings.TrimSpace(d.VerificationID)
|
||||
if verificationID == "" {
|
||||
var err error
|
||||
verificationID, err = d.requestVerificationID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var step2 verifyResp
|
||||
resp, err := d.accountClient.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"verification_id": verificationID,
|
||||
"verification_code": d.VerifyCode,
|
||||
"client_id": d.ClientID,
|
||||
}).
|
||||
SetResult(&step2).
|
||||
Post("/v1/auth/verification/verify")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.IsError() || step2.Error != "" || strings.TrimSpace(step2.VerificationToken) == "" {
|
||||
return fmt.Errorf("verify code failed: %s", d.accountErr(step2.ErrorDesc, step2.Error, resp))
|
||||
}
|
||||
|
||||
var out tokenResp
|
||||
resp, err = d.accountClient.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"verification_code": d.VerifyCode,
|
||||
"verification_token": step2.VerificationToken,
|
||||
"username": normalizePhoneE164(d.PhoneNumber),
|
||||
"client_id": d.ClientID,
|
||||
}).
|
||||
SetResult(&out).
|
||||
Post("/v1/auth/signin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
|
||||
return fmt.Errorf("signin failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
|
||||
}
|
||||
|
||||
d.AccessToken = strings.TrimSpace(out.AccessToken)
|
||||
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
|
||||
d.VerificationID = ""
|
||||
// One-time SMS code should not be reused after successful login.
|
||||
d.VerifyCode = ""
|
||||
op.MustSaveDriverStorage(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) prepareSMSCode(ctx context.Context) error {
|
||||
// Explicit send action should always refresh verification_id.
|
||||
d.VerificationID = ""
|
||||
if err := d.ensureCaptchaToken(ctx, false); err != nil {
|
||||
return err
|
||||
}
|
||||
verificationID, err := d.requestVerificationID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.VerificationID = verificationID
|
||||
d.SendCode = false
|
||||
op.MustSaveDriverStorage(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) requestVerificationID(ctx context.Context) (string, error) {
|
||||
req := d.accountClient.R().SetContext(ctx)
|
||||
if d.CaptchaToken != "" {
|
||||
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
|
||||
}
|
||||
|
||||
var step1 verificationResp
|
||||
resp, err := req.
|
||||
SetBody(map[string]any{
|
||||
"phone_number": normalizePhoneE164(d.PhoneNumber),
|
||||
"target": "ANY",
|
||||
"client_id": d.ClientID,
|
||||
}).
|
||||
SetResult(&step1).
|
||||
Post("/v1/auth/verification")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.IsError() || step1.Error != "" || strings.TrimSpace(step1.VerificationID) == "" {
|
||||
// If captcha token is expired/invalid, refresh it once and retry.
|
||||
if strings.Contains(step1.Error, "captcha_invalid") || strings.Contains(step1.ErrorDesc, "captcha_token expired") {
|
||||
if err := d.ensureCaptchaToken(ctx, true); err == nil {
|
||||
return d.requestVerificationID(ctx)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("request verification failed: %s", d.accountErr(step1.ErrorDesc, step1.Error, resp))
|
||||
}
|
||||
return strings.TrimSpace(step1.VerificationID), nil
|
||||
}
|
||||
|
||||
func (d *GuangYaPan) ensureCaptchaToken(ctx context.Context, force bool) error {
|
||||
if !force && d.CaptchaToken != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out captchaInitResp
|
||||
req := d.accountClient.R().SetContext(ctx)
|
||||
if d.CaptchaToken != "" {
|
||||
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
|
||||
}
|
||||
resp, err := req.
|
||||
SetBody(map[string]any{
|
||||
"client_id": d.ClientID,
|
||||
"action": "POST:/v1/auth/verification",
|
||||
"device_id": d.DeviceID,
|
||||
"meta": map[string]any{
|
||||
"username": normalizePhoneE164(d.PhoneNumber),
|
||||
"phone_number": normalizePhoneE164(d.PhoneNumber),
|
||||
"VERIFICATION_PHONE": normalizePhoneE164(d.PhoneNumber),
|
||||
},
|
||||
}).
|
||||
SetResult(&out).
|
||||
Post("/v1/shield/captcha/init")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.CaptchaToken) == "" {
|
||||
return fmt.Errorf("init captcha token failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
|
||||
}
|
||||
d.CaptchaToken = strings.TrimSpace(out.CaptchaToken)
|
||||
op.MustSaveDriverStorage(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interface compliance checks
|
||||
var (
|
||||
_ driver.Driver = (*GuangYaPan)(nil)
|
||||
_ driver.GetRooter = (*GuangYaPan)(nil)
|
||||
_ driver.Mkdir = (*GuangYaPan)(nil)
|
||||
_ driver.Move = (*GuangYaPan)(nil)
|
||||
_ driver.Copy = (*GuangYaPan)(nil)
|
||||
_ driver.Rename = (*GuangYaPan)(nil)
|
||||
_ driver.Remove = (*GuangYaPan)(nil)
|
||||
_ driver.PutResult = (*GuangYaPan)(nil)
|
||||
_ driver.WithDetails = (*GuangYaPan)(nil)
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user