mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 02:20:28 +00:00
Compare commits
93
Commits
feat/new_ui
...
v1.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0254f7c0 | ||
|
|
d36b345f92 | ||
|
|
bc5c818eae | ||
|
|
98bd3305b7 | ||
|
|
3f38848051 | ||
|
|
82d7f8ec81 | ||
|
|
a6981d58e9 | ||
|
|
1b1631b41a | ||
|
|
3eb6df55df | ||
|
|
6de63c3f63 | ||
|
|
969615f353 | ||
|
|
5a88b98314 | ||
|
|
2674b1c39e | ||
|
|
4c90b38247 | ||
|
|
93088b22f1 | ||
|
|
fe90c1d261 | ||
|
|
640e798d70 | ||
|
|
ba2df0ecff | ||
|
|
d61da67456 | ||
|
|
7c696ee6e0 | ||
|
|
7b840b4261 | ||
|
|
a3c1f84c2c | ||
|
|
8d33e1a5b3 | ||
|
|
bad7f1d06c | ||
|
|
af1c8b2719 | ||
|
|
d90af0b9d1 | ||
|
|
6016ee1d24 | ||
|
|
e275fd58bd | ||
|
|
c9626cb389 | ||
|
|
f86a379f93 | ||
|
|
a9eee26be7 | ||
|
|
abd16d5326 | ||
|
|
6756d82ffa | ||
|
|
71c9e6947a | ||
|
|
24db9bb58b | ||
|
|
de0afb1481 | ||
|
|
cb6331c20d | ||
|
|
b0e133b554 | ||
|
|
dbe37befa3 | ||
|
|
0d5c4e8e7f | ||
|
|
5515a1c48c | ||
|
|
3abf6ed933 | ||
|
|
af40e8aa63 | ||
|
|
64c5ece02c | ||
|
|
4ccbc3ebc5 | ||
|
|
14d9642d3c | ||
|
|
2f5fbfb17c | ||
|
|
49b4d99b5e | ||
|
|
9d9f1caaaa | ||
|
|
5bb8541385 | ||
|
|
f7189668fd | ||
|
|
1b36ed1807 | ||
|
|
07dc196c3c | ||
|
|
76bec67ab2 | ||
|
|
a2a9e3dc2a | ||
|
|
9a5680fb45 | ||
|
|
75a8a3a9ac | ||
|
|
3e8c85b945 | ||
|
|
90f70d1bf6 | ||
|
|
20a70d7f91 | ||
|
|
d33546a4b5 | ||
|
|
93614d46a8 | ||
|
|
04b0e691eb | ||
|
|
7f08fa0b23 | ||
|
|
df6a51d23c | ||
|
|
46c379d556 | ||
|
|
46cd9f7a3e | ||
|
|
5c2f66f4b6 | ||
|
|
cfdcb586ee | ||
|
|
55e6bdc4f7 | ||
|
|
b31b42301e | ||
|
|
7ce0d876b0 | ||
|
|
2fd2596aa0 | ||
|
|
e0973e4a34 | ||
|
|
4f7b8a05ad | ||
|
|
2927d1ef2b | ||
|
|
3451f577ab | ||
|
|
06671be0d8 | ||
|
|
8ae9363f0f | ||
|
|
5d84445f8f | ||
|
|
7c541e4047 | ||
|
|
3262e5a11b | ||
|
|
ff43a373cd | ||
|
|
7394d948e4 | ||
|
|
5afb3aa43b | ||
|
|
7705af6e68 | ||
|
|
684e060ef3 | ||
|
|
0c67792441 | ||
|
|
b0ef2c2a87 | ||
|
|
1977c2a0fc | ||
|
|
d289708f9e | ||
|
|
cd5fd45667 | ||
|
|
50bc9ab129 |
No files matched your search
@@ -40,25 +40,41 @@ template: |
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2">🪟 Windows</td>
|
||||
<td><code>EXE</code></td>
|
||||
<td rowspan="4">🪟 Windows</td>
|
||||
<td rowspan="2"><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Portable</code></td>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🍎 MacOS</td>
|
||||
<td><code>DMG</code></td>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td
|
||||
<td><code>Flathub</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://flathub.org/apps/com.gopeed.Gopeed">📥</a></td>
|
||||
</tr>
|
||||
|
||||
+170
-64
@@ -13,8 +13,10 @@ on:
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
FLUTTER_VERSION: "3.24.5"
|
||||
GO_VERSION: "1.24"
|
||||
FLUTTER_VERSION: "3.41.2"
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,19 +36,62 @@ jobs:
|
||||
|
||||
build-windows:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
|
||||
runs-on: windows-latest
|
||||
needs: [get-release]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
os: windows-11-arm
|
||||
llvm_ver: "20251202" # Only ARM64
|
||||
flutter_channel: "main"
|
||||
flutter_version: "7e1c8868"
|
||||
- arch: amd64
|
||||
os: windows-2022
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
|
||||
# Install LLVM environment only on ARM64
|
||||
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||
if: matrix.arch == 'arm64'
|
||||
run: |
|
||||
$ver = "${{ matrix.llvm_ver }}"
|
||||
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
|
||||
$zip = "$env:RUNNER_TEMP\\llvm.zip"
|
||||
$extract = "$env:RUNNER_TEMP\\extract"
|
||||
$target = "C:\\clangarm64"
|
||||
|
||||
curl -L $url -o $zip
|
||||
rm -r -fo $extract,$target -ea Ignore
|
||||
mkdir $extract | Out-Null
|
||||
|
||||
tar -xf $zip -C $extract
|
||||
mv (Get-ChildItem $extract)[0].FullName $target
|
||||
|
||||
$b = "$target\\bin"
|
||||
"CC=$b\\clang.exe" >> $env:GITHUB_ENV
|
||||
"CXX=$b\\clang++.exe" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_BIN=$b" >> $env:GITHUB_ENV
|
||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl"
|
||||
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
|
||||
@@ -56,21 +101,30 @@ jobs:
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater
|
||||
cd ui/flutter
|
||||
$TAG = "v$env:VERSION"
|
||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable"
|
||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
$system = "C:\Windows\System32"
|
||||
$release = "build\windows\x64\runner\Release\"
|
||||
cp $mingw\libstdc++-6.dll $release
|
||||
cp $mingw\libgcc_s_seh-1.dll $release
|
||||
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||
# for amd64
|
||||
$release = "build\windows\x64\runner\Release\"
|
||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||
cp $mingw\libstdc++-6.dll $release
|
||||
cp $mingw\libgcc_s_seh-1.dll $release
|
||||
} else {
|
||||
# for ARM64
|
||||
$release = "build\windows\arm64\runner\Release\"
|
||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||
cp $mingw\libc++.dll $release
|
||||
cp $mingw\libunwind.dll $release
|
||||
}
|
||||
cp $mingw\libwinpthread-1.dll $release
|
||||
cp $system\msvcp140.dll $release
|
||||
cp $system\vcruntime140.dll $release
|
||||
cp $system\vcruntime140_1.dll $release
|
||||
|
||||
New-Item -Path build\windows\Output -ItemType Directory
|
||||
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-amd64-portable.zip"
|
||||
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-${{ matrix.arch }}-portable.zip"
|
||||
|
||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller"
|
||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
cd build/windows
|
||||
echo @"
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
@@ -116,7 +170,7 @@ jobs:
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: ".\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs;
|
||||
Source: ".\${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }}\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs;
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
|
||||
[UninstallDelete]
|
||||
@@ -130,18 +184,17 @@ jobs:
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
"@ > setup.iss
|
||||
iscc.exe setup.iss
|
||||
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-amd64.exe"
|
||||
Compress-Archive -Path "Output\Gopeed-$TAG-windows-amd64.exe" -DestinationPath "Output\Gopeed-$TAG-windows-amd64.zip"
|
||||
Remove-Item -Path "Output\Gopeed-$TAG-windows-amd64.exe"
|
||||
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
|
||||
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||
asset_path: ui/flutter/build/windows/Output/*
|
||||
overwrite: true
|
||||
build-macos-arm64-lib:
|
||||
build-macos-amd64-lib:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15-intel
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -157,15 +210,18 @@ jobs:
|
||||
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/updater
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-lib
|
||||
name: macos-amd64-lib
|
||||
path: |
|
||||
libgopeed.dylib
|
||||
host
|
||||
updater
|
||||
build-macos:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
|
||||
runs-on: macos-13
|
||||
needs: [get-release, build-macos-arm64-lib]
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
channel: [arm64, amd64, universal]
|
||||
needs: [get-release, build-macos-amd64-lib]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
@@ -183,35 +239,47 @@ jobs:
|
||||
npm install -g appdmg
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-lib
|
||||
path: ui/flutter/lib-arm64
|
||||
name: macos-amd64-lib
|
||||
path: ui/flutter/lib-amd64
|
||||
- if: ${{ matrix.channel == 'arm64' }}
|
||||
name: Build Arm64 Libraries
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||
- if: ${{ matrix.channel == 'amd64' }}
|
||||
name: Build Amd64 Libraries
|
||||
run: |
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib
|
||||
cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host
|
||||
cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater
|
||||
- if: ${{ matrix.channel == 'universal' }}
|
||||
name: Build Universal Libraries
|
||||
run: |
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
|
||||
# arm64 lib
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater
|
||||
|
||||
# amd64 lib
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64
|
||||
cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64
|
||||
cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64
|
||||
|
||||
# universal lib
|
||||
lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64
|
||||
lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64
|
||||
lipo -create -output ui/flutter/assets/exec/updater ui/flutter/.temp/updater.arm64 ui/flutter/.temp/updater.amd64
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
PROJECT_DIR=$(pwd)
|
||||
|
||||
# amd64 lib
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/lib-amd64/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/lib-amd64/host github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/lib-amd64/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||
# universal binary
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/amd64-lib
|
||||
cp ui/flutter/lib-arm64/libgopeed.dylib ui/flutter/macos/Frameworks/arm64-lib
|
||||
cp ui/flutter/lib-amd64/host ui/flutter/macos/Frameworks/amd64-host
|
||||
cp ui/flutter/lib-arm64/host ui/flutter/macos/Frameworks/arm64-host
|
||||
cp ui/flutter/lib-amd64/updater ui/flutter/macos/Frameworks/amd64-updater
|
||||
cp ui/flutter/lib-arm64/updater ui/flutter/macos/Frameworks/arm64-updater
|
||||
cd ui/flutter/macos/Frameworks
|
||||
lipo -create -output libgopeed.dylib amd64-lib arm64-lib
|
||||
lipo -create -output host amd64-host arm64-host
|
||||
lipo -create -output updater amd64-updater arm64-updater
|
||||
rm -rf amd64-lib arm64-lib amd64-host arm64-host amd64-updater arm64-updater
|
||||
mv host ../../assets/exec/host
|
||||
|
||||
cd $PROJECT_DIR/ui/flutter
|
||||
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg"
|
||||
cd ui/flutter
|
||||
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
cd build/macos/Build/Products/Release
|
||||
cat>appdmg.json<<EOF
|
||||
{
|
||||
@@ -225,6 +293,13 @@ jobs:
|
||||
EOF
|
||||
mkdir dist
|
||||
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
|
||||
|
||||
if [[ "${{ matrix.channel }}" == "arm64" ]]; then
|
||||
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg
|
||||
fi
|
||||
if [[ "${{ matrix.channel }}" == "amd64" ]]; then
|
||||
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-amd64.dmg
|
||||
fi
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
@@ -243,24 +318,15 @@ jobs:
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- if: matrix.os == 'ubuntu-22.04'
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- if: matrix.os == 'ubuntu-22.04-arm'
|
||||
uses: subosito/flutter-action@v2
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: master
|
||||
- run: |
|
||||
sudo apt update -y
|
||||
# flutter build dependencies
|
||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev
|
||||
# appimage build dependencies
|
||||
sudo apt install -y libfuse2 locate
|
||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace libkeybinder-3.0-dev
|
||||
arch=$(uname -m)
|
||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||
chmod +x appimagetool
|
||||
sudo mv appimagetool /usr/local/bin/
|
||||
- name: Build
|
||||
@@ -272,6 +338,29 @@ jobs:
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||
|
||||
cd ui/flutter
|
||||
|
||||
ICON_PATH=$(find assets -name "logo.png" -o -name "icon.png" | head -n 1)
|
||||
[ -z "$ICON_PATH" ] && ICON_PATH="assets/icon.png" && touch assets/icon.png
|
||||
|
||||
convert "$ICON_PATH" assets/icon.svg
|
||||
ICON_PATH="assets/icon.svg"
|
||||
|
||||
RPM_ARCH=$(uname -m)
|
||||
|
||||
mkdir -p linux/packaging/rpm
|
||||
cat << EOF > linux/packaging/rpm/make_config.yaml
|
||||
display_name: Gopeed
|
||||
icon: $ICON_PATH
|
||||
summary: High speed downloader.
|
||||
description: A high speed downloader that supports all platforms.
|
||||
group: Applications/Internet
|
||||
license: GPL-3.0
|
||||
url: https://github.com/GopeedLab/gopeed
|
||||
vendor: GopeedLab
|
||||
maintainer: GopeedLab <[email protected]>
|
||||
build_arch: $RPM_ARCH
|
||||
EOF
|
||||
|
||||
cat << EOF > distribute_options.yaml
|
||||
output: dist/
|
||||
releases:
|
||||
@@ -284,6 +373,8 @@ jobs:
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxAppImage
|
||||
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||
- name: deb
|
||||
package:
|
||||
platform: linux
|
||||
@@ -291,12 +382,22 @@ jobs:
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxDeb
|
||||
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||
- name: rpm
|
||||
package:
|
||||
platform: linux
|
||||
target: rpm
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxRpm
|
||||
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||
EOF
|
||||
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
|
||||
flutter_distributor release --name linux
|
||||
cd dist/*
|
||||
|
||||
# Get current running architecture, x86_64 -> amd64 and aarch64 -> arm64
|
||||
ARCH="amd64"
|
||||
if [[ "${{ matrix.os }}" == *-arm ]]; then
|
||||
ARCH="arm64"
|
||||
@@ -304,6 +405,7 @@ jobs:
|
||||
|
||||
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
|
||||
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
|
||||
mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
@@ -385,6 +487,7 @@ jobs:
|
||||
- cmake
|
||||
- curl
|
||||
- libgtk-3-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- ninja-build
|
||||
- unzip
|
||||
- xz-utils
|
||||
@@ -400,13 +503,14 @@ jobs:
|
||||
plugin: nil
|
||||
stage-packages:
|
||||
- libgtk-3-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- libappindicator3-dev
|
||||
override-build: |
|
||||
# work around pub get stack overflow # https://github.com/dart-lang/sdk/issues/51068#issuecomment-1396588253
|
||||
set +e
|
||||
dart pub get
|
||||
set -eux
|
||||
flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap"
|
||||
flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap" --dart-define="GA4_MEASUREMENT_ID=$GA4_MEASUREMENT_ID" --dart-define="GA4_API_SECRET=$GA4_API_SECRET"
|
||||
mkdir -p \$CRAFT_PART_INSTALL/bin/
|
||||
cp -r build/linux/*/release/bundle/* \$CRAFT_PART_INSTALL/bin/
|
||||
gpu-2404:
|
||||
@@ -471,8 +575,8 @@ jobs:
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
|
||||
flutter build apk --dart-define="UPDATE_CHANNEL=androidApk"
|
||||
flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk"
|
||||
flutter build apk --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
mkdir dist
|
||||
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk
|
||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk
|
||||
@@ -486,7 +590,7 @@ jobs:
|
||||
overwrite: true
|
||||
build-ios:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-14
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -505,7 +609,7 @@ jobs:
|
||||
gomobile init
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa"
|
||||
flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
mkdir Payload
|
||||
cp -r build/ios/iphoneos/Runner.app Payload
|
||||
zip -r -y Payload.zip Payload/Runner.app
|
||||
@@ -534,7 +638,8 @@ jobs:
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
cd ui/flutter
|
||||
flutter build web --no-web-resources-cdn
|
||||
flutter build web --no-web-resources-cdn --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
cd ../../
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
mkdir -p dist/zip
|
||||
@@ -650,7 +755,8 @@ jobs:
|
||||
- name: Build flutter web
|
||||
run: |
|
||||
cd ui/flutter
|
||||
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker"
|
||||
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
cd ../../
|
||||
rm -rf cmd/web/dist
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- "Dockerfile"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
GO_VERSION: "1.24"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
// Localize external Google Fonts (fonts.gstatic.com) references in Flutter Web build output.
|
||||
//
|
||||
// Usage (post-build):
|
||||
// # Run AFTER: flutter build web --no-web-resources-cdn
|
||||
// dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
//
|
||||
// What it does:
|
||||
// In main.dart.js:
|
||||
// - Find any referenced resources under: https://fonts.gstatic.com/s/
|
||||
// (including the common pattern where Flutter concatenates the base URL with a relative path)
|
||||
// - Download them into: build/web/assets/gstatic/<path>
|
||||
// - Rewrite "https://fonts.gstatic.com/s/" -> "assets/gstatic/" so runtime loads locally
|
||||
// - Only download font subsets needed by supported locales (parsed from message.dart)
|
||||
//
|
||||
// Notes:
|
||||
// - This script DOES NOT call `flutter build web`. It only patches the output.
|
||||
// - This is a best-effort post-process. Always verify in browser devtools.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
/// Get font families required for a specific locale based on language/script.
|
||||
/// Uses language code prefix to automatically detect the script system.
|
||||
/// Returns empty set if base fonts (Latin/Cyrillic/Greek) are sufficient.
|
||||
Set<String> _getFontFamiliesForLocale(String locale) {
|
||||
final lang = locale.toLowerCase().split('_').first;
|
||||
|
||||
// CJK languages - need specific large font files
|
||||
if (lang == 'zh') {
|
||||
// Simplified vs Traditional Chinese
|
||||
if (locale.contains('cn') || locale.contains('sg')) {
|
||||
return {'notosanssc'}; // Simplified Chinese
|
||||
}
|
||||
return {'notosanstc', 'notosanshk'}; // Traditional Chinese (TW, HK, MO)
|
||||
}
|
||||
if (lang == 'ja') return {'notosansjp'}; // Japanese
|
||||
if (lang == 'ko') return {'notosanskr'}; // Korean
|
||||
|
||||
// Arabic script languages
|
||||
if (lang == 'ar') return {'notosansarabic'}; // Arabic
|
||||
if (lang == 'fa') return {'notosansarabic'}; // Persian/Farsi
|
||||
if (lang == 'ur') return {'notosansarabic'}; // Urdu
|
||||
if (lang == 'ps') return {'notosansarabic'}; // Pashto
|
||||
if (lang == 'ku') return {'notosansarabic'}; // Kurdish (Arabic script)
|
||||
|
||||
// Hebrew script
|
||||
if (lang == 'he' || lang == 'yi') return {'notosanshebrew'};
|
||||
|
||||
// South Asian scripts
|
||||
if (lang == 'hi' || lang == 'mr' || lang == 'ne' || lang == 'sa') {
|
||||
return {'notosansdevanagari'}; // Hindi, Marathi, Nepali, Sanskrit
|
||||
}
|
||||
if (lang == 'bn' || lang == 'as')
|
||||
return {'notosansbengali'}; // Bengali, Assamese
|
||||
if (lang == 'ta')
|
||||
return {'notosanstamil', 'notosanstamilsupplement'}; // Tamil
|
||||
if (lang == 'te') return {'notosanstelugu'}; // Telugu
|
||||
if (lang == 'kn') return {'notosanskannada'}; // Kannada
|
||||
if (lang == 'ml') return {'notosansmalayalam'}; // Malayalam
|
||||
if (lang == 'gu') return {'notosansgujarati'}; // Gujarati
|
||||
if (lang == 'pa') return {'notosansgurmukhi'}; // Punjabi (Gurmukhi)
|
||||
if (lang == 'or') return {'notosansoriya'}; // Odia/Oriya
|
||||
if (lang == 'si') return {'notosanssinhala'}; // Sinhala
|
||||
|
||||
// Southeast Asian scripts
|
||||
if (lang == 'th') return {'notosansthai'}; // Thai
|
||||
if (lang == 'lo') return {'notosanslao'}; // Lao
|
||||
if (lang == 'my') return {'notosansmyanmar'}; // Myanmar/Burmese
|
||||
if (lang == 'km') return {'notosanskhmer'}; // Khmer/Cambodian
|
||||
if (lang == 'jv') return {'notosansjavanese'}; // Javanese
|
||||
|
||||
// Other scripts
|
||||
if (lang == 'ka') return {'notosansgeorgian'}; // Georgian
|
||||
if (lang == 'hy') return {'notosansarmenian'}; // Armenian
|
||||
if (lang == 'am' || lang == 'ti')
|
||||
return {'notosansethiopic'}; // Amharic, Tigrinya
|
||||
if (lang == 'mn') return {'notosansmongolian'}; // Mongolian
|
||||
|
||||
// Latin/Cyrillic/Greek based languages - base notosans is sufficient
|
||||
// Includes: English, German, French, Spanish, Italian, Portuguese,
|
||||
// Russian, Ukrainian, Polish, Turkish, Vietnamese, Greek, etc.
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Base font families that are always included regardless of locale.
|
||||
/// These provide core functionality and are relatively small.
|
||||
const _baseFontFamilies = <String>{
|
||||
'notosans', // Base Latin/Cyrillic/Greek/Vietnamese
|
||||
'roboto', // Material Design default font
|
||||
'notosanssymbols', // Common symbols
|
||||
'notosanssymbols2', // Additional symbols
|
||||
'notosansmath', // Math symbols
|
||||
'notomusic', // Music notation symbols
|
||||
// Note: notocoloremoji (~24MB) and notoemoji (~860KB) are excluded
|
||||
// to reduce bundle size. Add them back if emoji support is needed.
|
||||
};
|
||||
|
||||
/// Parse supported locales from message.dart file.
|
||||
/// Reads the import statements and extracts locale codes like 'zh_cn', 'en_us', etc.
|
||||
Set<String> _parseSupportedLocales(File messageFile) {
|
||||
final locales = <String>{};
|
||||
|
||||
if (!messageFile.existsSync()) {
|
||||
_fail('Warning: message.dart not found, only base fonts will be used');
|
||||
}
|
||||
|
||||
final content = messageFile.readAsStringSync();
|
||||
|
||||
// Match import statements like: import 'langs/zh_cn.dart';
|
||||
final importRegex = RegExp(r"import\s+'langs/(\w+)\.dart'");
|
||||
for (final match in importRegex.allMatches(content)) {
|
||||
final locale = match.group(1);
|
||||
if (locale != null) {
|
||||
locales.add(locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (locales.isEmpty) {
|
||||
_fail(
|
||||
'Warning: No locales found in message.dart, only base fonts will be used');
|
||||
}
|
||||
|
||||
return locales;
|
||||
}
|
||||
|
||||
/// Get required font families for the given locales.
|
||||
/// Returns a set of font family names that should be included.
|
||||
/// Automatically detects required fonts based on language code prefix.
|
||||
Set<String> _getRequiredFontFamilies(Set<String> locales) {
|
||||
final families = <String>{..._baseFontFamilies};
|
||||
for (final locale in locales) {
|
||||
final localeFamilies = _getFontFamiliesForLocale(locale);
|
||||
families.addAll(localeFamilies);
|
||||
}
|
||||
return families;
|
||||
}
|
||||
|
||||
/// Check if a font file path should be included based on required font families.
|
||||
/// Font paths have the format: "fontfamily/version/filename.ext"
|
||||
/// Example: "notosanssc/v36/xxx.ttf" -> font family is "notosanssc"
|
||||
bool _fontMatchesRequiredFamilies(
|
||||
String fontPath, Set<String> requiredFamilies) {
|
||||
// Extract font family from path (first segment before '/')
|
||||
final slashIndex = fontPath.indexOf('/');
|
||||
if (slashIndex == -1) {
|
||||
// No slash found, include by default (unusual path format)
|
||||
return true;
|
||||
}
|
||||
|
||||
final fontFamily = fontPath.substring(0, slashIndex).toLowerCase();
|
||||
return requiredFamilies.contains(fontFamily);
|
||||
}
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
try {
|
||||
// This script is intentionally argument-free for CI convenience.
|
||||
// It assumes it is executed from the Flutter project directory (ui/flutter),
|
||||
// but will also try to locate pubspec.yaml by walking up.
|
||||
final flutterDir = _findFlutterProjectRoot(Directory.current);
|
||||
|
||||
// Parse supported locales from message.dart
|
||||
final messageFile =
|
||||
File(_join(flutterDir.path, 'lib', 'i18n', 'message.dart'));
|
||||
final supportedLocales = _parseSupportedLocales(messageFile);
|
||||
stdout.writeln('Supported locales: ${supportedLocales.join(', ')}');
|
||||
|
||||
// Get required font families based on supported locales
|
||||
final requiredFamilies = _getRequiredFontFamilies(supportedLocales);
|
||||
stdout.writeln('Required font families: ${requiredFamilies.join(', ')}');
|
||||
|
||||
final webDir = Directory(_join(flutterDir.path, 'build', 'web'));
|
||||
if (!webDir.existsSync()) {
|
||||
_fail(
|
||||
'Web build directory not found: ${webDir.path}\n'
|
||||
'Did you run: flutter build web --no-web-resources-cdn ?',
|
||||
);
|
||||
}
|
||||
|
||||
final mainJs = File(_join(webDir.path, 'main.dart.js'));
|
||||
if (!mainJs.existsSync()) {
|
||||
_fail('main.dart.js not found at: ${mainJs.path}');
|
||||
}
|
||||
|
||||
final gstaticRoot = Directory(_join(webDir.path, 'assets', 'gstatic'));
|
||||
if (!gstaticRoot.existsSync()) gstaticRoot.createSync(recursive: true);
|
||||
|
||||
const gstaticSPrefix = 'https://fonts.gstatic.com/s/';
|
||||
final original = mainJs.readAsStringSync();
|
||||
|
||||
// Relative font asset paths which Flutter's loader typically concatenates with:
|
||||
// https://fonts.gstatic.com/s/
|
||||
// Keep this as a best-effort heuristic to cover the common concatenation pattern.
|
||||
// Some gstatic assets include extra dot segments like: notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.0.woff2
|
||||
// Allow dots in the path, while still restricting to font-like extensions.
|
||||
final relAssetRegex = RegExp(
|
||||
r"""["']([a-zA-Z0-9/_\.-]+\.(?:woff2|woff|ttf|otf|eot|svg))["']""",
|
||||
caseSensitive: false,
|
||||
);
|
||||
final relAssetsUnderS = <String>{};
|
||||
for (final m in relAssetRegex.allMatches(original)) {
|
||||
final p = m.group(1);
|
||||
if (p == null || p.isEmpty) continue;
|
||||
if (p.contains('://') || p.startsWith('data:')) continue;
|
||||
if (p.startsWith('/') ||
|
||||
p.startsWith('assets/') ||
|
||||
p.startsWith('packages/')) continue;
|
||||
if (p.contains('..')) continue;
|
||||
relAssetsUnderS.add(p);
|
||||
}
|
||||
|
||||
if (relAssetsUnderS.isEmpty) {
|
||||
_fail('No fonts.gstatic.com assets found in main.dart.js.');
|
||||
}
|
||||
|
||||
// Build a download plan: dest relative path (under assets/gstatic/) -> URL.
|
||||
// Filter fonts based on required font families to reduce package size.
|
||||
final downloads = <String, Uri>{};
|
||||
final skippedFonts = <String, Set<String>>{};
|
||||
|
||||
for (final rel in relAssetsUnderS) {
|
||||
if (_fontMatchesRequiredFamilies(rel, requiredFamilies)) {
|
||||
downloads[rel] = Uri.parse('$gstaticSPrefix$rel');
|
||||
} else {
|
||||
// Track skipped fonts and their paths for fallback generation
|
||||
final slashIndex = rel.indexOf('/');
|
||||
final family = slashIndex > 0 ? rel.substring(0, slashIndex) : rel;
|
||||
skippedFonts.putIfAbsent(family, () => <String>{}).add(rel);
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedFonts.isNotEmpty) {
|
||||
stdout.writeln(
|
||||
'Skipped ${skippedFonts.length} font families not required by supported locales:',
|
||||
);
|
||||
stdout.writeln(' ${skippedFonts.keys.join(', ')}');
|
||||
}
|
||||
|
||||
if (downloads.isNotEmpty) {
|
||||
stdout.writeln(
|
||||
'Found ${downloads.length} fonts.gstatic.com assets to download...',
|
||||
);
|
||||
|
||||
// Reuse a single HttpClient to avoid creating hundreds of short-lived
|
||||
// connections (can be flaky on some environments).
|
||||
final httpClient = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 30)
|
||||
..idleTimeout = const Duration(seconds: 30)
|
||||
..maxConnectionsPerHost = 6;
|
||||
try {
|
||||
for (final entry in downloads.entries) {
|
||||
final relPath = entry.key;
|
||||
final url = entry.value;
|
||||
final destPath = relPath.replaceAll('/', Platform.pathSeparator);
|
||||
final dest = File(_join(gstaticRoot.path, destPath));
|
||||
await _downloadIfMissing(httpClient, url, dest);
|
||||
}
|
||||
} finally {
|
||||
httpClient.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate empty fallback font files for skipped fonts to prevent infinite loading
|
||||
// Empty files will cause browsers to fail fast instead of retrying indefinitely
|
||||
if (skippedFonts.isNotEmpty) {
|
||||
var fallbackCount = 0;
|
||||
for (final entry in skippedFonts.entries) {
|
||||
for (final relPath in entry.value) {
|
||||
final destPath = relPath.replaceAll('/', Platform.pathSeparator);
|
||||
final dest = File(_join(gstaticRoot.path, destPath));
|
||||
if (!dest.existsSync()) {
|
||||
dest.parent.createSync(recursive: true);
|
||||
// Create an empty file - browsers will recognize it as invalid and skip
|
||||
dest.writeAsBytesSync([]);
|
||||
fallbackCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fallbackCount > 0) {
|
||||
stdout.writeln(
|
||||
'Generated $fallbackCount empty fallback font files to prevent infinite loading',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite remote prefix so requests become:
|
||||
// <origin>/assets/gstatic/<...>.woff2
|
||||
final replacedGstatic = original.replaceAll(
|
||||
gstaticSPrefix,
|
||||
'assets/gstatic/',
|
||||
);
|
||||
|
||||
if (replacedGstatic == original && downloads.isEmpty) {
|
||||
stdout.writeln(
|
||||
'No changes applied (no fonts.gstatic.com references found).',
|
||||
);
|
||||
exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
mainJs.writeAsStringSync(replacedGstatic);
|
||||
|
||||
stdout.writeln('Patched: ${mainJs.path}');
|
||||
if (downloads.isNotEmpty) {
|
||||
stdout.writeln(
|
||||
' - downloaded ${downloads.length} files into: ${gstaticRoot.path}',
|
||||
);
|
||||
}
|
||||
stdout.writeln(' - fonts.gstatic.com rewritten -> assets/gstatic/');
|
||||
} catch (e, st) {
|
||||
stderr.writeln('ERROR: $e');
|
||||
stderr.writeln(st);
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadIfMissing(HttpClient client, Uri url, File dest) async {
|
||||
if (dest.existsSync() && dest.lengthSync() > 0) return;
|
||||
|
||||
dest.parent.createSync(recursive: true);
|
||||
|
||||
// Network can be flaky in CI. Retry a few times on transient errors.
|
||||
const maxAttempts = 4;
|
||||
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
final tmp = File('${dest.path}.tmp');
|
||||
try {
|
||||
try {
|
||||
final req = await client.getUrl(url);
|
||||
|
||||
final resp = await req.close().timeout(const Duration(seconds: 60));
|
||||
if (resp.statusCode != 200) {
|
||||
// 404 is not transient in practice - fail fast with a clear message.
|
||||
if (resp.statusCode == 404) {
|
||||
_fail('Missing gstatic asset (404): $url');
|
||||
}
|
||||
throw HttpException('HTTP ${resp.statusCode}', uri: url);
|
||||
}
|
||||
|
||||
// Stream to temp file first, then rename to avoid leaving partial files.
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
final sink = tmp.openWrite();
|
||||
await resp.pipe(sink).timeout(const Duration(seconds: 120));
|
||||
|
||||
if (!tmp.existsSync() || tmp.lengthSync() == 0) {
|
||||
throw const FormatException('Downloaded asset is empty');
|
||||
}
|
||||
if (dest.existsSync()) dest.deleteSync();
|
||||
tmp.renameSync(dest.path);
|
||||
return;
|
||||
} finally {
|
||||
// no-op: HttpClient lifecycle managed by the caller
|
||||
}
|
||||
} on TimeoutException {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('Timeout downloading asset: $url');
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('Socket error downloading asset: $url ($e)');
|
||||
}
|
||||
} on HttpException catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('HTTP error downloading asset: $url ($e)');
|
||||
}
|
||||
} on FileSystemException catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
_fail('File write error downloading asset: $url ($e)');
|
||||
} catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('Unexpected error downloading asset: $url ($e)');
|
||||
}
|
||||
}
|
||||
|
||||
// Backoff before retrying.
|
||||
final backoffSeconds = 1 << (attempt - 1);
|
||||
stdout.writeln(
|
||||
'Retrying (${attempt + 1}/$maxAttempts) for: $url (wait ${backoffSeconds}s)',
|
||||
);
|
||||
await Future.delayed(Duration(seconds: backoffSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
Directory _findFlutterProjectRoot(Directory start) {
|
||||
var dir = start;
|
||||
for (var i = 0; i < 10; i++) {
|
||||
final pubspec = File(_join(dir.path, 'pubspec.yaml'));
|
||||
if (pubspec.existsSync()) return dir;
|
||||
final parent = dir.parent;
|
||||
if (parent.path == dir.path) break;
|
||||
dir = parent;
|
||||
}
|
||||
// Fallback: use current directory, error messages will explain what is missing.
|
||||
return start;
|
||||
}
|
||||
|
||||
Never _fail(String msg) {
|
||||
throw FormatException(msg);
|
||||
}
|
||||
|
||||
String _join(String a, [String? b, String? c, String? d]) {
|
||||
final parts = <String>[
|
||||
a,
|
||||
if (b != null) b,
|
||||
if (c != null) c,
|
||||
if (d != null) d,
|
||||
];
|
||||
return parts.join(Platform.pathSeparator);
|
||||
}
|
||||
+57
-14
@@ -25,10 +25,11 @@ on:
|
||||
- ".github/workflows/test.yml"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
FLUTTER_VERSION: "3.24.5"
|
||||
GO_VERSION: "1.24"
|
||||
FLUTTER_VERSION: "3.41.2"
|
||||
|
||||
jobs:
|
||||
# lint:
|
||||
@@ -53,27 +54,68 @@ jobs:
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Run Unit tests.
|
||||
run: go test $(go list ./... | grep -v /cmd/ | grep -v /bind/) -v -covermode=count -coverprofile=coverage.txt
|
||||
run: |
|
||||
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
||||
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
|
||||
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
||||
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
||||
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
||||
mv coverage_filtered.txt coverage.txt
|
||||
- uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
build-desktop:
|
||||
name: Build desktop check
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm ]
|
||||
needs: [ test ]
|
||||
include:
|
||||
- os: windows-2022
|
||||
- os: windows-11-arm
|
||||
llvm_ver: "20251202" # Only ARM64
|
||||
flutter_channel: "main"
|
||||
flutter_version: "7e1c8868"
|
||||
- os: macos-latest
|
||||
- os: ubuntu-22.04
|
||||
- os: ubuntu-22.04-arm
|
||||
flutter_channel: "main"
|
||||
name: Build desktop check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||
if: matrix.os == 'windows-11-arm'
|
||||
run: |
|
||||
$ver = "${{ matrix.llvm_ver }}"
|
||||
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
|
||||
$zip = "$env:RUNNER_TEMP\\llvm.zip"
|
||||
$extract = "$env:RUNNER_TEMP\\extract"
|
||||
$target = "C:\\clangarm64"
|
||||
|
||||
curl -L $url -o $zip
|
||||
rm -r -fo $extract,$target -ea Ignore
|
||||
mkdir $extract | Out-Null
|
||||
|
||||
tar -xf $zip -C $extract
|
||||
mv (Get-ChildItem $extract)[0].FullName $target
|
||||
|
||||
$b = "$target\\bin"
|
||||
"CC=$b\\clang.exe" >> $env:GITHUB_ENV
|
||||
"CXX=$b\\clang++.exe" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_BIN=$b" >> $env:GITHUB_ENV
|
||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: master
|
||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||
- if: runner.os == 'Windows'
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
|
||||
@@ -87,7 +129,7 @@ jobs:
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build linux
|
||||
@@ -96,8 +138,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ macos-latest, ubuntu-latest ]
|
||||
needs: [ test ]
|
||||
os: [macos-14, ubuntu-latest]
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
@@ -129,7 +171,7 @@ jobs:
|
||||
build-web:
|
||||
name: Build web check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ test ]
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
@@ -142,6 +184,7 @@ jobs:
|
||||
run: |
|
||||
cd ui/flutter
|
||||
flutter build web --no-web-resources-cdn
|
||||
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
cd ../../
|
||||
rm -rf cmd/web/dist
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
@@ -149,7 +192,7 @@ jobs:
|
||||
build-docker:
|
||||
name: Build docker check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ test ]
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.23.3 AS go
|
||||
FROM golang:1.24.11-alpine3.23 AS go
|
||||
WORKDIR /app
|
||||
COPY ./go.mod ./go.sum ./
|
||||
RUN go mod download
|
||||
@@ -8,7 +8,7 @@ RUN CGO_ENABLED=0 go build -tags nosqlite,web \
|
||||
-ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \
|
||||
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
|
||||
|
||||
FROM alpine:3.18
|
||||
FROM alpine:3.23
|
||||
LABEL maintainer="monkeyWie"
|
||||
WORKDIR /app
|
||||
COPY --from=go /app/dist/gopeed ./
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# [](https://gopeed.com)
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -24,22 +25,38 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2">🪟 Windows</td>
|
||||
<td><code>EXE</code></td>
|
||||
<td rowspan="4">🪟 Windows</td>
|
||||
<td rowspan="2"><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Portable</code></td>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🍎 MacOS</td>
|
||||
<td><code>DMG</code></td>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
@@ -162,6 +179,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
|
||||
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||
|
||||
## 📱 WeChat Official Account
|
||||
|
||||
Follow our WeChat Official Account to get the latest updates and news.
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 Donate
|
||||
|
||||
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
||||
@@ -178,8 +201,8 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
||||
|
||||
### 🌍 Environment
|
||||
|
||||
1. Golang 1.23+
|
||||
2. Flutter 3.24+
|
||||
1. Golang 1.24+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 Clone
|
||||
|
||||
|
||||
+20
-5
@@ -1,10 +1,11 @@
|
||||
# [](https://gopeed.com)
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -35,11 +36,19 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🍎 MacOS</td>
|
||||
<td><code>DMG</code></td>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
@@ -151,6 +160,12 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
||||
|
||||
### 🛠️ コマンドツール
|
||||
|
||||
## 📱 WeChat 公式アカウント
|
||||
|
||||
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 寄付
|
||||
|
||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
||||
@@ -167,8 +182,8 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
||||
|
||||
### 🌍 環境
|
||||
|
||||
1. Go 言語 1.23+
|
||||
2. Flutter 3.24+
|
||||
1. Go 言語 1.24+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 クローン
|
||||
|
||||
|
||||
+20
-5
@@ -1,10 +1,11 @@
|
||||
# [](https://gopeed.com)
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -35,11 +36,19 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🍎 MacOS</td>
|
||||
<td><code>DMG</code></td>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
@@ -157,6 +166,12 @@ Sử dụng `go install`:
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
## 📱 WeChat Official Account
|
||||
|
||||
Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất.
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 Quyên góp
|
||||
|
||||
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
||||
@@ -173,8 +188,8 @@ Dự án này được chia thành hai phần, phần giao diện sử dụng `f
|
||||
|
||||
### 🌍 Environment
|
||||
|
||||
1. Golang 1.23+
|
||||
2. Flutter 3.24+
|
||||
1. Golang 1.24+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 Clone
|
||||
|
||||
|
||||
+20
-5
@@ -1,10 +1,11 @@
|
||||
# [](https://gopeed.com)
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/zh/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -35,11 +36,19 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🍎 MacOS</td>
|
||||
<td><code>DMG</code></td>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
@@ -161,6 +170,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
|
||||
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||
|
||||
## 📱 微信公众号
|
||||
|
||||
关注公众号获取项目最新动态和资讯。
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 赞助
|
||||
|
||||
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
||||
@@ -177,8 +192,8 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
||||
|
||||
### 🌍 环境要求
|
||||
|
||||
1. Golang 1.23+
|
||||
2. Flutter 3.24+
|
||||
1. Golang 1.24+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 克隆项目
|
||||
|
||||
|
||||
+20
-5
@@ -1,10 +1,11 @@
|
||||
# [](https://gopeed.com)
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -35,11 +36,19 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🍎 MacOS</td>
|
||||
<td><code>DMG</code></td>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
@@ -158,6 +167,12 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
## 📱 微信公眾號
|
||||
|
||||
關注公眾號獲取項目最新動態和資訊。
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 贊助
|
||||
|
||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
||||
@@ -174,8 +189,8 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
|
||||
### 🌍 開發環境
|
||||
|
||||
1. Golang 1.23+
|
||||
2. Flutter 3.24+
|
||||
1. Golang 1.24+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 克隆項目
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" width="1200" height="400">
|
||||
<rect width="1200" height="400" fill="#040605"/>
|
||||
<g fill="#5dc76c">
|
||||
<rect x="-100" y="50" width="150" height="2" opacity="0.6">
|
||||
<animate attributeName="x" from="-200" to="1300" dur="0.8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-200" y="150" width="200" height="1" opacity="0.4">
|
||||
<animate attributeName="x" from="-300" to="1300" dur="1.2s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-50" y="250" width="180" height="3" opacity="0.8">
|
||||
<animate attributeName="x" from="-200" to="1300" dur="0.6s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-150" y="350" width="220" height="2" opacity="0.5">
|
||||
<animate attributeName="x" from="-300" to="1300" dur="1s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-100" y="100" width="100" height="1" opacity="0.7">
|
||||
<animate attributeName="x" from="-150" to="1300" dur="0.9s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-250" y="300" width="250" height="2" opacity="0.3">
|
||||
<animate attributeName="x" from="-400" to="1300" dur="1.5s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="logoGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(600, 160)">
|
||||
<!-- Floating animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,-8; 0,8; 0,-8" dur="3s" repeatCount="indefinite" />
|
||||
|
||||
<!-- Pulsing glow behind logo -->
|
||||
<circle cx="0" cy="0" r="45" fill="#5dc76c" filter="url(#logoGlow)" opacity="0.3">
|
||||
<animate attributeName="opacity" values="0.1; 0.6; 0.1" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="r" values="40; 65; 40" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- Breathing scale animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="scale" values="0.95; 1.05; 0.95" dur="2s" repeatCount="indefinite" />
|
||||
<g transform="scale(0.12) translate(-512, -512)">
|
||||
<path style="opacity:0.998" fill="#5dc76c" d="M 951.5,763.5 C 940.739,755.276 929.405,747.776 917.5,741C 908.477,736.319 899.143,732.485 889.5,729.5C 885.844,625.779 840.844,545.613 754.5,489C 674.802,441.453 590.135,430.12 500.5,455C 419.755,480.605 359.588,530.771 320,605.5C 278.585,565.816 230.752,555.649 176.5,575C 131.948,594.935 106.448,629.102 100,677.5C 99.3333,685.833 99.3333,694.167 100,702.5C 102.081,714.824 105.081,726.824 109,738.5C 95.4882,745.492 82.9882,753.826 71.5,763.5C 37.7121,705.639 16.5454,643.306 8,576.5C -9.41698,416.759 35.9164,278.093 144,160.5C 244.661,58.2827 367.161,5.94939 511.5,3.5C 678.975,7.48829 814.142,74.8216 917,205.5C 1011.33,336.426 1040,480.759 1003,638.5C 991.564,682.671 974.397,724.337 951.5,763.5 Z"/>
|
||||
<path style="opacity:0.999" fill="#fefefe" d="M 951.5,763.5 C 949.28,768.272 946.78,772.938 944,777.5C 938.305,786.913 932.138,795.913 925.5,804.5C 915.954,796.888 905.288,791.055 893.5,787C 878.282,782.958 863.115,778.791 848,774.5C 854.013,716.891 841.346,663.891 810,615.5C 768.341,553.228 711.174,512.395 638.5,493C 560.311,479.566 490.311,497.566 428.5,547C 411.963,560.536 397.13,575.703 384,592.5C 366.745,616.923 348.911,640.923 330.5,664.5C 310.135,651.302 290.469,637.135 271.5,622C 238.448,602.307 206.782,604.307 176.5,628C 159.375,642.752 148.875,661.252 145,683.5C 143.968,692.716 144.968,701.716 148,710.5C 154.334,725.339 160.167,740.339 165.5,755.5C 138.118,764.688 114.451,779.688 94.5,800.5C 85.653,788.968 77.9863,776.635 71.5,763.5C 82.9882,753.826 95.4882,745.492 109,738.5C 105.081,726.824 102.081,714.824 100,702.5C 99.3333,694.167 99.3333,685.833 100,677.5C 106.448,629.102 131.948,594.935 176.5,575C 230.752,555.649 278.585,565.816 320,605.5C 359.588,530.771 419.755,480.605 500.5,455C 590.135,430.12 674.802,441.453 754.5,489C 840.844,545.613 885.844,625.779 889.5,729.5C 899.143,732.485 908.477,736.319 917.5,741C 929.405,747.776 940.739,755.276 951.5,763.5 Z"/>
|
||||
<path style="opacity:0.998" fill="#5dc76c" d="M 925.5,804.5 C 850.791,908.608 750.791,975.775 625.5,1006C 609.308,1009.7 592.975,1012.53 576.5,1014.5C 615.708,969.938 655.042,925.438 694.5,881C 664.168,880.5 633.835,880.333 603.5,880.5C 603.5,811.167 603.5,741.833 603.5,672.5C 542.167,672.5 480.833,672.5 419.5,672.5C 419.5,741.5 419.5,810.5 419.5,879.5C 388.493,879.167 357.493,879.5 326.5,880.5C 326.624,881.107 326.957,881.44 327.5,881.5C 366.805,925.808 406.138,970.141 445.5,1014.5C 318.63,996.317 212.796,939.651 128,844.5C 115.85,830.525 104.683,815.859 94.5,800.5C 114.451,779.688 138.118,764.688 165.5,755.5C 160.167,740.339 154.334,725.339 148,710.5C 144.968,701.716 143.968,692.716 145,683.5C 148.875,661.252 159.375,642.752 176.5,628C 206.782,604.307 238.448,602.307 271.5,622C 290.469,637.135 310.135,651.302 330.5,664.5C 348.911,640.923 366.745,616.923 384,592.5C 397.13,575.703 411.963,560.536 428.5,547C 490.311,497.566 560.311,479.566 638.5,493C 711.174,512.395 768.341,553.228 810,615.5C 841.346,663.891 854.013,716.891 848,774.5C 863.115,778.791 878.282,782.958 893.5,787C 905.288,791.055 915.954,796.888 925.5,804.5 Z"/>
|
||||
<path style="opacity:0.999" fill="#fefefe" d="M 576.5,1014.5 C 532.836,1019.82 489.169,1019.82 445.5,1014.5C 406.138,970.141 366.805,925.808 327.5,881.5C 358.167,880.833 388.833,880.167 419.5,879.5C 419.5,810.5 419.5,741.5 419.5,672.5C 480.833,672.5 542.167,672.5 603.5,672.5C 603.5,741.833 603.5,811.167 603.5,880.5C 633.835,880.333 664.168,880.5 694.5,881C 655.042,925.438 615.708,969.938 576.5,1014.5 Z"/>
|
||||
<path style="opacity:1" fill="#ade2b5" d="M 419.5,879.5 C 388.833,880.167 358.167,880.833 327.5,881.5C 326.957,881.44 326.624,881.107 326.5,880.5C 357.493,879.5 388.493,879.167 419.5,879.5 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<text x="600" y="300" font-family="sans-serif" font-size="72" font-style="italic" font-weight="900" text-anchor="middle" fill="#fff" letter-spacing="8">GOPEED</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
|
||||
+3
-2
@@ -2,13 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const progressWidth = 20
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Dial() (net.Conn, error) {
|
||||
// Get binary path
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.Dial("unix", filepath.Join(filepath.Dir(exe), "gopeed_host.sock"))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
)
|
||||
|
||||
func Dial() (net.Conn, error) {
|
||||
return winio.DialPipe(`\\.\pipe\gopeed_host`, nil)
|
||||
}
|
||||
+110
-25
@@ -1,22 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
)
|
||||
|
||||
const identifier = "gopeed"
|
||||
|
||||
type Message struct {
|
||||
Method string `json:"method"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
@@ -26,33 +28,117 @@ type Response struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
var apiMap = map[string]func(params json.RawMessage) (data any, err error){
|
||||
"ping": func(params json.RawMessage) (data any, err error) {
|
||||
processes, err := process.Processes()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
func check() (data bool, err error) {
|
||||
conn, err := Dial()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for _, p := range processes {
|
||||
name, err := p.Name()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
func wakeup(hidden bool) error {
|
||||
running, _ := check()
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(name), strings.ToLower(identifier)) {
|
||||
return true, nil
|
||||
}
|
||||
uri := "gopeed:"
|
||||
if hidden {
|
||||
uri = uri + "?hidden=true"
|
||||
}
|
||||
if err := browser.OpenURL(uri); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if ok, _ := check(); ok {
|
||||
return nil
|
||||
}
|
||||
return false, nil
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("start gopeed failed")
|
||||
}
|
||||
|
||||
// postToFlutter sends a POST request to Flutter RPC server
|
||||
func postToFlutter(path string, body []byte, headers map[string]string, timeout time.Duration) (*http.Response, error) {
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return Dial()
|
||||
},
|
||||
},
|
||||
Timeout: timeout,
|
||||
}
|
||||
req, err := http.NewRequest("POST", "http://127.0.0.1"+path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
var apiMap = map[string]func(message *Message) (data any, err error){
|
||||
"ping": func(message *Message) (data any, err error) {
|
||||
return check()
|
||||
},
|
||||
"create": func(params json.RawMessage) (data any, err error) {
|
||||
var strParams string
|
||||
if err = json.Unmarshal(params, &strParams); err != nil {
|
||||
"wakeup": func(message *Message) (data any, err error) {
|
||||
silent := false
|
||||
if v, ok := message.Meta["silent"]; ok {
|
||||
silent, _ = v.(bool)
|
||||
}
|
||||
err = wakeup(silent)
|
||||
return
|
||||
},
|
||||
"create": func(message *Message) (data any, err error) {
|
||||
buf, err := message.Params.MarshalJSON()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = browser.OpenURL(fmt.Sprintf("%s:///create?params=%s", identifier, strParams))
|
||||
|
||||
silent := false
|
||||
if v, ok := message.Meta["silent"]; ok {
|
||||
silent, _ = v.(bool)
|
||||
}
|
||||
|
||||
if err := wakeup(silent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if message.Meta != nil {
|
||||
metaJson, _ := json.Marshal(message.Meta)
|
||||
headers["X-Gopeed-Host-Meta"] = string(metaJson)
|
||||
}
|
||||
_, err = postToFlutter("/create", buf, headers, 10*time.Second)
|
||||
return
|
||||
},
|
||||
"forward": func(message *Message) (data any, err error) {
|
||||
buf, err := message.Params.MarshalJSON()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := postToFlutter("/forward", buf, nil, 60*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var respData map[string]json.RawMessage
|
||||
if err := json.Unmarshal(respBody, &respData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respData, nil
|
||||
},
|
||||
}
|
||||
|
||||
// go build -ldflags="-s -w" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/host
|
||||
@@ -63,7 +149,6 @@ func main() {
|
||||
var length uint32
|
||||
if err := binary.Read(os.Stdin, binary.NativeEndian, &length); err != nil {
|
||||
if err == io.EOF {
|
||||
// Connection closed by Chrome
|
||||
return
|
||||
}
|
||||
sendError("Failed to read message length: " + err.Error())
|
||||
@@ -88,7 +173,7 @@ func main() {
|
||||
var data any
|
||||
var err error
|
||||
if handler, ok := apiMap[message.Method]; ok {
|
||||
data, err = handler(message.Params)
|
||||
data, err = handler(&message)
|
||||
} else {
|
||||
err = errors.New("Unknown method: " + message.Method)
|
||||
}
|
||||
|
||||
+5
-6
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +18,7 @@ func main() {
|
||||
pid := flag.Int("pid", 0, "PID of the process to update")
|
||||
updateChannel := flag.String("channel", "", "Update channel")
|
||||
packagePath := flag.String("asset", "", "Path to the package asset")
|
||||
exeDir := flag.String("exeDir", "", "Directory of the entry executable")
|
||||
logPath := flag.String("log", "", "Log file path")
|
||||
flag.Parse()
|
||||
|
||||
@@ -43,7 +43,7 @@ func main() {
|
||||
restart bool
|
||||
err error
|
||||
)
|
||||
if restart, err = update(*pid, *updateChannel, *packagePath); err != nil {
|
||||
if restart, err = update(*pid, *updateChannel, *packagePath, *exeDir); err != nil {
|
||||
log.Printf("Update failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func update(pid int, updateChannel, packagePath string) (restart bool, err error) {
|
||||
func update(pid int, updateChannel, packagePath, exeDir string) (restart bool, err error) {
|
||||
killSignalChan := make(chan any, 1)
|
||||
|
||||
go func() {
|
||||
@@ -76,9 +76,8 @@ func update(pid int, updateChannel, packagePath string) (restart bool, err error
|
||||
}
|
||||
}()
|
||||
|
||||
appDir := filepath.Dir(os.Args[0])
|
||||
log.Printf("Updating process updateChannel=%s packagePath=%s appDir=%s\n", updateChannel, packagePath, appDir)
|
||||
if restart, err = install(killSignalChan, updateChannel, packagePath, appDir); err != nil {
|
||||
log.Printf("Updating process updateChannel=%s packagePath=%s exeDir=%s\n", updateChannel, packagePath, exeDir)
|
||||
if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {
|
||||
return false, errors.Wrap(err, "failed to install package")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,20 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
|
||||
mountPoint := ""
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
if strings.Contains(line, "/Volumes/") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 2 {
|
||||
mountPoint = fields[len(fields)-1]
|
||||
// Find the /Volumes/ path in the line
|
||||
// hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed
|
||||
// or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1
|
||||
idx := strings.Index(line, "/Volumes/")
|
||||
if idx != -1 {
|
||||
// Extract everything from /Volumes/ onwards and trim whitespace
|
||||
mountPoint = strings.TrimSpace(line[idx:])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mountPoint == "" {
|
||||
return fmt.Errorf("failed to get mount point")
|
||||
return fmt.Errorf("failed to get mount point from hdiutil output: %s", string(output))
|
||||
}
|
||||
|
||||
// Detach the mounted DMG
|
||||
@@ -45,7 +49,7 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
|
||||
return err
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("no .app found in dmg")
|
||||
return fmt.Errorf("no .app found in dmg, mountPoint: %s", mountPoint)
|
||||
}
|
||||
|
||||
killSignalChan <- nil
|
||||
|
||||
+166
-51
@@ -3,19 +3,23 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
type args struct {
|
||||
Address *string `json:"address"`
|
||||
Port *int `json:"port"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
ApiToken *string `json:"apiToken"`
|
||||
StorageDir *string `json:"storageDir"`
|
||||
Address *string `json:"address"`
|
||||
Port *int `json:"port"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
ApiToken *string `json:"apiToken"`
|
||||
StorageDir *string `json:"storageDir"`
|
||||
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
|
||||
// DownloadConfig when the first time to start the server, it will be configured as initial value
|
||||
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
|
||||
|
||||
@@ -23,62 +27,173 @@ type args struct {
|
||||
}
|
||||
|
||||
func parse() *args {
|
||||
var cliArgs args
|
||||
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
|
||||
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
|
||||
cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username")
|
||||
cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd")
|
||||
cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling basic authentication")
|
||||
cliArgs.StorageDir = flag.String("d", "", "Storage directory")
|
||||
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
|
||||
cfg := &args{}
|
||||
|
||||
cliConfig := loadCliArgs()
|
||||
loadConfigFile(cfg, *cliConfig.configPath)
|
||||
loadEnvVars(cfg)
|
||||
// override with non-default command line arguments
|
||||
overrideWithCliArgs(cfg, cliConfig)
|
||||
// set default values
|
||||
setDefaults(cfg, cliConfig)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// loadCliArgs parses command line arguments and returns initial config
|
||||
func loadCliArgs() *args {
|
||||
cfg := &args{}
|
||||
cfg.Address = flag.String("A", "0.0.0.0", "Bind Address")
|
||||
cfg.Port = flag.Int("P", 9999, "Bind Port")
|
||||
cfg.Username = flag.String("u", "gopeed", "Web Authentication Username")
|
||||
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
||||
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
||||
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
||||
whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
|
||||
cfg.configPath = flag.String("c", "./config.json", "Config file path")
|
||||
flag.Parse()
|
||||
|
||||
// args priority: config file > cli args
|
||||
cfgArgs := loadConfig(*cliArgs.configPath)
|
||||
if cfgArgs.Address == nil {
|
||||
cfgArgs.Address = cliArgs.Address
|
||||
// Parse white download directories from comma-separated string
|
||||
if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
|
||||
dirs := strings.Split(*whiteDownloadDirs, ",")
|
||||
for i := range dirs {
|
||||
dirs[i] = strings.TrimSpace(dirs[i])
|
||||
}
|
||||
cfg.WhiteDownloadDirs = dirs
|
||||
}
|
||||
if cfgArgs.Port == nil {
|
||||
cfgArgs.Port = cliArgs.Port
|
||||
}
|
||||
if cfgArgs.Username == nil {
|
||||
cfgArgs.Username = cliArgs.Username
|
||||
}
|
||||
if cfgArgs.Password == nil {
|
||||
cfgArgs.Password = cliArgs.Password
|
||||
}
|
||||
if cfgArgs.ApiToken == nil {
|
||||
cfgArgs.ApiToken = cliArgs.ApiToken
|
||||
}
|
||||
if cfgArgs.StorageDir == nil {
|
||||
cfgArgs.StorageDir = cliArgs.StorageDir
|
||||
}
|
||||
return cfgArgs
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadConfig(path string) *args {
|
||||
var args args
|
||||
// overrideWithCliArgs overrides config with non-empty command line arguments
|
||||
func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "A":
|
||||
cfg.Address = cliConfig.Address
|
||||
case "P":
|
||||
cfg.Port = cliConfig.Port
|
||||
case "u":
|
||||
cfg.Username = cliConfig.Username
|
||||
case "p":
|
||||
cfg.Password = cliConfig.Password
|
||||
case "T":
|
||||
cfg.ApiToken = cliConfig.ApiToken
|
||||
case "d":
|
||||
cfg.StorageDir = cliConfig.StorageDir
|
||||
case "w":
|
||||
cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
|
||||
case "c":
|
||||
cfg.configPath = cliConfig.configPath
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
// setDefaults sets default values for any unset configuration fields
|
||||
func setDefaults(cfg *args, cliConfig *args) {
|
||||
if cfg.Address == nil {
|
||||
cfg.Address = cliConfig.Address
|
||||
}
|
||||
if cfg.Port == nil {
|
||||
cfg.Port = cliConfig.Port
|
||||
}
|
||||
if cfg.Username == nil {
|
||||
cfg.Username = cliConfig.Username
|
||||
}
|
||||
if cfg.Password == nil {
|
||||
cfg.Password = cliConfig.Password
|
||||
}
|
||||
if cfg.ApiToken == nil {
|
||||
cfg.ApiToken = cliConfig.ApiToken
|
||||
}
|
||||
if cfg.StorageDir == nil {
|
||||
cfg.StorageDir = cliConfig.StorageDir
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfigFile loads configuration from file
|
||||
func loadConfigFile(cfg *args, configPath string) {
|
||||
if !filepath.IsAbs(configPath) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Println("config dir get failed, reason:" + err.Error())
|
||||
return &args
|
||||
return
|
||||
}
|
||||
path = filepath.Join(dir, path)
|
||||
configPath = filepath.Join(dir, configPath)
|
||||
}
|
||||
file, err := os.ReadFile(path)
|
||||
|
||||
file, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &args
|
||||
return
|
||||
}
|
||||
fmt.Println("config file read failed, reason:" + err.Error())
|
||||
return &args
|
||||
return
|
||||
}
|
||||
if err = json.Unmarshal(file, &args); err != nil {
|
||||
fmt.Println("config file parse failed, reason:" + err.Error())
|
||||
return &args
|
||||
|
||||
if err = json.Unmarshal(file, cfg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// loadEnvVars loads configuration from environment variables with prefix GOPEED_
|
||||
func loadEnvVars(cfg *args) {
|
||||
v := reflect.ValueOf(cfg).Elem()
|
||||
t := reflect.TypeOf(cfg).Elem()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := t.Field(i)
|
||||
|
||||
// Get json tag as environment variable suffix
|
||||
jsonTag := fieldType.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove options like omitempty
|
||||
if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 {
|
||||
jsonTag = jsonTag[:commaIdx]
|
||||
}
|
||||
|
||||
// Convert to uppercase and add GOPEED_ prefix
|
||||
envKey := "GOPEED_" + strings.ToUpper(jsonTag)
|
||||
envValue := os.Getenv(envKey)
|
||||
|
||||
if envValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set value based on field type
|
||||
if field.Kind() == reflect.Ptr {
|
||||
if field.IsNil() {
|
||||
// Create new pointer instance
|
||||
newVal := reflect.New(field.Type().Elem())
|
||||
field.Set(newVal)
|
||||
}
|
||||
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.String:
|
||||
field.Elem().SetString(envValue)
|
||||
case reflect.Int:
|
||||
if intVal, err := strconv.Atoi(envValue); err == nil {
|
||||
field.Elem().SetInt(int64(intVal))
|
||||
}
|
||||
default:
|
||||
// For complex types like DownloadConfig, try JSON unmarshaling
|
||||
if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {
|
||||
var config base.DownloaderStoreConfig
|
||||
if err := json.Unmarshal([]byte(envValue), &config); err == nil {
|
||||
field.Set(reflect.ValueOf(&config))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if field.Kind() == reflect.Slice {
|
||||
// Handle non-pointer slice types (like []string for WhiteDownloadDirs)
|
||||
if field.Type().Elem().Kind() == reflect.String {
|
||||
dirs := strings.Split(envValue, ",")
|
||||
for i := range dirs {
|
||||
dirs[i] = strings.TrimSpace(dirs[i])
|
||||
}
|
||||
field.Set(reflect.ValueOf(dirs))
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("config file loaded: %s\n", path)
|
||||
return &args
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+19
-17
@@ -6,11 +6,12 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/cmd"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/GopeedLab/gopeed/cmd"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
@@ -23,36 +24,37 @@ func main() {
|
||||
}
|
||||
|
||||
args := parse()
|
||||
var webBasicAuth *model.WebBasicAuth
|
||||
var webAuth *model.WebAuth
|
||||
if isNotBlank(args.Username) && isNotBlank(args.Password) {
|
||||
webBasicAuth = &model.WebBasicAuth{
|
||||
webAuth = &model.WebAuth{
|
||||
Username: *args.Username,
|
||||
Password: *args.Password,
|
||||
}
|
||||
}
|
||||
|
||||
var dir string
|
||||
var storageDir string
|
||||
if args.StorageDir != nil && *args.StorageDir != "" {
|
||||
dir = *args.StorageDir
|
||||
storageDir = *args.StorageDir
|
||||
} else {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
dir = filepath.Dir(exe)
|
||||
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
||||
}
|
||||
|
||||
cfg := &model.StartConfig{
|
||||
Network: "tcp",
|
||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: filepath.Join(dir, "storage"),
|
||||
ApiToken: *args.ApiToken,
|
||||
DownloadConfig: args.DownloadConfig,
|
||||
ProductionMode: true,
|
||||
WebEnable: true,
|
||||
WebFS: sub,
|
||||
WebBasicAuth: webBasicAuth,
|
||||
Network: "tcp",
|
||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: storageDir,
|
||||
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
||||
ApiToken: *args.ApiToken,
|
||||
DownloadConfig: args.DownloadConfig,
|
||||
ProductionMode: true,
|
||||
WebEnable: true,
|
||||
WebFS: sub,
|
||||
WebAuth: webAuth,
|
||||
}
|
||||
cmd.Start(cfg)
|
||||
}
|
||||
|
||||
@@ -1,139 +1,164 @@
|
||||
module github.com/GopeedLab/gopeed
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.9
|
||||
|
||||
toolchain go1.23.1
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
github.com/anacrolix/missinggo/v2 v2.8.0
|
||||
github.com/anacrolix/torrent v1.57.2-0.20241017235801-4d8437a05621
|
||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05
|
||||
github.com/bodgit/sevenzip v1.6.1
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||
github.com/go-git/go-git/v5 v5.8.1
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/imroc/req/v3 v3.52.2
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
github.com/mattn/go-ieproxy v0.0.12
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
||||
go.etcd.io/bbolt v1.3.11
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
|
||||
golang.org/x/sync v0.9.0
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ebitengine/purego v0.8.1 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/anacrolix/btree v0.1.1 // indirect
|
||||
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.51.0 // indirect
|
||||
github.com/refraction-networking/utls v1.6.7 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/mock v0.5.1 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||
github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||
github.com/anacrolix/chansync v0.6.0 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.22.0 // indirect
|
||||
github.com/anacrolix/envpprof v1.4.0 // indirect
|
||||
github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect
|
||||
github.com/anacrolix/go-libutp v1.3.1 // indirect
|
||||
github.com/anacrolix/log v0.16.0 // indirect
|
||||
github.com/anacrolix/chansync v0.7.0 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
||||
github.com/anacrolix/envpprof v1.5.0 // indirect
|
||||
github.com/anacrolix/generics v0.2.0 // indirect
|
||||
github.com/anacrolix/go-libutp v1.3.2 // indirect
|
||||
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||
github.com/anacrolix/mmsg v1.1.1 // indirect
|
||||
github.com/anacrolix/multiless v0.4.0 // indirect
|
||||
github.com/anacrolix/stm v0.5.0 // indirect
|
||||
github.com/anacrolix/sync v0.5.3 // indirect
|
||||
github.com/anacrolix/sync v0.6.0 // indirect
|
||||
github.com/anacrolix/upnp v0.1.4 // indirect
|
||||
github.com/anacrolix/utp v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/benbjohnson/immutable v0.4.3 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.15.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cloudflare/circl v1.3.3 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.4.1 // indirect
|
||||
github.com/go-llsqlite/adapter v0.1.0 // indirect
|
||||
github.com/go-llsqlite/crawshaw v0.5.5 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-llsqlite/adapter v0.2.0 // indirect
|
||||
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pion/datachannel v1.5.9 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.4 // indirect
|
||||
github.com/pion/ice/v4 v4.0.2 // indirect
|
||||
github.com/pion/interceptor v0.1.37 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pion/datachannel v1.6.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||
github.com/pion/ice/v4 v4.2.0 // indirect
|
||||
github.com/pion/interceptor v0.1.42 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.14 // indirect
|
||||
github.com/pion/rtp v1.8.9 // indirect
|
||||
github.com/pion/sctp v1.8.33 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/pion/webrtc/v4 v4.0.2 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/rtp v1.10.0 // indirect
|
||||
github.com/pion/sctp v1.9.1 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.17 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/protolambda/ctxlock v0.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.24.12
|
||||
github.com/skeema/knownhosts v1.2.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/tidwall/btree v1.7.0 // indirect
|
||||
github.com/tidwall/btree v1.8.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
go.opentelemetry.io/otel v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.32.0 // indirect
|
||||
golang.org/x/crypto v0.29.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/net v0.31.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
golang.org/x/tools v0.27.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
lukechampine.com/blake3 v1.3.0 // indirect
|
||||
modernc.org/libc v1.61.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.33.1 // indirect
|
||||
zombiezen.com/go/sqlite v1.4.0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.43.0 // indirect
|
||||
zombiezen.com/go/sqlite v1.4.2 // indirect
|
||||
)
|
||||
@@ -24,8 +24,10 @@ type DefaultFileController struct {
|
||||
|
||||
func NewController() *Controller {
|
||||
return &Controller{
|
||||
GetConfig: func(v any) {},
|
||||
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) { return nil },
|
||||
GetConfig: func(v any) {},
|
||||
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) {
|
||||
return requestProxy.ToHandler()
|
||||
},
|
||||
FileController: &DefaultFileController{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// Fetcher defines the interface for a download protocol.
|
||||
// Each download task will have a corresponding Fetcher instance for the management of the download task
|
||||
type Fetcher interface {
|
||||
Setup(ctl *controller.Controller)
|
||||
// Resolve resource info from request
|
||||
Resolve(req *base.Request) error
|
||||
// Create ready to download, but not started
|
||||
Create(opts *base.Options) error
|
||||
Resolve(req *base.Request, opts *base.Options) error
|
||||
Start() error
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP: can modify Request info (URL, headers, etc.)
|
||||
// For BT: can modify SelectFiles (via opts.SelectFiles)
|
||||
Patch(req *base.Request, opts *base.Options) error
|
||||
Pause() error
|
||||
Close() error
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ type config struct {
|
||||
ListenPort int `json:"listenPort"`
|
||||
Trackers []string `json:"trackers"`
|
||||
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
||||
SeedKeep bool `json:"seedKeep`
|
||||
SeedKeep bool `json:"seedKeep"`
|
||||
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
||||
SeedRatio float64 `json:"seedRatio"`
|
||||
// SeedTime is the time in seconds to seed after downloading is complete.
|
||||
|
||||
+125
-59
@@ -4,13 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -19,15 +12,23 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
client *torrent.Client
|
||||
lock sync.Mutex
|
||||
torrentDirMap = make(map[string]string)
|
||||
ftMap = make(map[string]*fileTorrentImpl)
|
||||
closeCtx context.Context
|
||||
closeFunc func()
|
||||
cfg *torrent.ClientConfig
|
||||
client *torrent.Client
|
||||
lock sync.Mutex
|
||||
closeCtx context.Context
|
||||
closeFunc func()
|
||||
)
|
||||
|
||||
type Fetcher struct {
|
||||
@@ -70,21 +71,12 @@ func (f *Fetcher) initClient() (err error) {
|
||||
closeCtx, closeFunc = context.WithCancel(context.Background())
|
||||
}
|
||||
|
||||
cfg := torrent.NewDefaultClientConfig()
|
||||
cfg = torrent.NewDefaultClientConfig()
|
||||
cfg.Seed = true
|
||||
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
|
||||
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
|
||||
cfg.ListenPort = f.config.ListenPort
|
||||
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
|
||||
cfg.DefaultStorage = newFileOpts(newFileClientOpts{
|
||||
ClientBaseDir: cfg.DataDir,
|
||||
HandleFileTorrent: func(infoHash metainfo.Hash, ft *fileTorrentImpl) {
|
||||
if dir, ok := torrentDirMap[infoHash.String()]; ok {
|
||||
ft.setTorrentDir(dir)
|
||||
}
|
||||
ftMap[infoHash.String()] = ft
|
||||
},
|
||||
})
|
||||
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
|
||||
cfg.TrackerDialContext = dnsResolver.DialContext
|
||||
client, err = torrent.NewClient(cfg)
|
||||
@@ -99,11 +91,12 @@ func (f *Fetcher) initClient() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
if err := base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||
return err
|
||||
}
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
if err := f.addTorrent(req, false); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,23 +104,13 @@ func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Create(opts *base.Options) (err error) {
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Res != nil {
|
||||
torrentDirMap[f.meta.Res.Hash] = opts.Path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() (err error) {
|
||||
if !f.torrentReady.Load() {
|
||||
if err = f.addTorrent(f.meta.Req, false); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if ft, ok := ftMap[f.meta.Res.Hash]; ok {
|
||||
ft.setTorrentDir(f.meta.Opts.Path)
|
||||
}
|
||||
|
||||
files := f.torrent.Files()
|
||||
// If the user does not specify the file to download, all files will be downloaded by default
|
||||
if f.data.Progress == nil {
|
||||
@@ -147,12 +130,12 @@ func (f *Fetcher) Start() (err error) {
|
||||
file.Download()
|
||||
}
|
||||
}
|
||||
f.torrent.AllowDataDownload()
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() (err error) {
|
||||
f.torrentReady.Store(false)
|
||||
f.safeDrop()
|
||||
f.torrent.DisallowDataDownload()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -249,6 +232,59 @@ func (f *Fetcher) isDone() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Patch modifies the BT task settings.
|
||||
// Invalid file indices are silently ignored.
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.SelectFiles != nil {
|
||||
selectFiles := opts.SelectFiles
|
||||
|
||||
// Get file count from resource metadata
|
||||
fileCount := 0
|
||||
if f.meta.Res != nil {
|
||||
fileCount = len(f.meta.Res.Files)
|
||||
}
|
||||
|
||||
// Filter out invalid indices (silently ignore)
|
||||
validSelectFiles := make([]int, 0, len(selectFiles))
|
||||
for _, idx := range selectFiles {
|
||||
if idx >= 0 && idx < fileCount {
|
||||
validSelectFiles = append(validSelectFiles, idx)
|
||||
}
|
||||
}
|
||||
|
||||
if f.torrent != nil {
|
||||
files := f.torrent.Files()
|
||||
|
||||
// Cancel all current file downloads first
|
||||
f.torrent.CancelPieces(0, f.torrent.NumPieces())
|
||||
|
||||
// Apply new file selection
|
||||
if len(validSelectFiles) == len(files) {
|
||||
f.torrent.DownloadAll()
|
||||
} else {
|
||||
for _, selectIndex := range validSelectFiles {
|
||||
file := files[selectIndex]
|
||||
file.Download()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.meta.Opts.SelectFiles = validSelectFiles
|
||||
// Recalculate the resource size based on new selection
|
||||
if f.meta.Res != nil {
|
||||
f.meta.Res.CalcSize(validSelectFiles)
|
||||
}
|
||||
// Reset progress tracking for new file selection
|
||||
f.data.Progress = make(fetcher.Progress, len(validSelectFiles))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) updateRes() {
|
||||
res := &base.Resource{
|
||||
Range: true,
|
||||
@@ -355,12 +391,20 @@ func (f *Fetcher) WaitUpload() (err error) {
|
||||
}
|
||||
|
||||
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||
return
|
||||
}
|
||||
if err = f.initClient(); err != nil {
|
||||
return
|
||||
}
|
||||
schema := util.ParseSchema(req.URL)
|
||||
privateTorrent := false
|
||||
var spec *torrent.TorrentSpec
|
||||
if schema == "MAGNET" {
|
||||
f.torrent, err = client.AddMagnet(req.URL)
|
||||
spec, err = torrent.TorrentSpecFromMagnetUri(req.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var reader io.Reader
|
||||
if schema == "FILE" {
|
||||
@@ -388,34 +432,56 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
if err != nil && !strings.Contains(err.Error(), "expected EOF") {
|
||||
return err
|
||||
}
|
||||
f.torrent, err = client.AddTorrent(metaInfo)
|
||||
|
||||
info, er := metaInfo.UnmarshalInfo()
|
||||
if er != nil {
|
||||
return er
|
||||
}
|
||||
|
||||
if info.Private != nil && *info.Private {
|
||||
privateTorrent = true
|
||||
}
|
||||
spec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
ClientBaseDir: cfg.DataDir,
|
||||
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
||||
return f.meta.Opts.Path
|
||||
},
|
||||
})
|
||||
f.torrent, _, err = client.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// use map to deduplicate
|
||||
trackers := make(map[string]bool)
|
||||
if req.Extra != nil {
|
||||
extra := req.Extra.(*bt.ReqExtra)
|
||||
if len(extra.Trackers) > 0 {
|
||||
for _, tracker := range extra.Trackers {
|
||||
// Do not add external tracker to a private torrent.
|
||||
if !privateTorrent {
|
||||
// use map to deduplicate
|
||||
trackers := make(map[string]bool)
|
||||
if req.Extra != nil {
|
||||
extra := req.Extra.(*bt.ReqExtra)
|
||||
if len(extra.Trackers) > 0 {
|
||||
for _, tracker := range extra.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(f.config.Trackers) > 0 {
|
||||
for _, tracker := range f.config.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(f.config.Trackers) > 0 {
|
||||
for _, tracker := range f.config.Trackers {
|
||||
trackers[tracker] = true
|
||||
if len(trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for tracker := range trackers {
|
||||
announceList = append(announceList, []string{tracker})
|
||||
}
|
||||
f.torrent.AddTrackers(announceList)
|
||||
}
|
||||
}
|
||||
if len(trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for tracker := range trackers {
|
||||
announceList = append(announceList, []string{tracker})
|
||||
}
|
||||
f.torrent.AddTrackers(announceList)
|
||||
}
|
||||
<-f.torrent.GotInfo()
|
||||
f.torrentReady.Store(true)
|
||||
|
||||
|
||||
@@ -3,17 +3,18 @@ package bt
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
gohttp "net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
)
|
||||
|
||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||
@@ -30,7 +31,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||
err = fetcher.Resolve(&base.Request{
|
||||
URL: dataUri,
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -80,7 +81,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -110,7 +111,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -144,7 +145,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/test.unclean.torrent",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
@@ -155,7 +156,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
uri := "file:///" + file
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: uri,
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
@@ -238,3 +239,130 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
||||
fetcher.Setup(newController)
|
||||
return fetcher
|
||||
}
|
||||
|
||||
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
||||
// It tests modifying selected files after Resolve (without downloading).
|
||||
func TestFetcher_Patch(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
|
||||
// Resolve a multi-file torrent
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "./testdata/test.torrent",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial state: 3 files, all selected by default
|
||||
meta := f.Meta()
|
||||
if len(meta.Res.Files) != 3 {
|
||||
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
||||
}
|
||||
if len(meta.Opts.SelectFiles) != 3 {
|
||||
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
|
||||
}
|
||||
|
||||
// Total size of all files: 107484864
|
||||
totalSize := int64(107484864)
|
||||
if meta.Res.Size != totalSize {
|
||||
t.Fatalf("Expected total size %d, got %d", totalSize, meta.Res.Size)
|
||||
}
|
||||
|
||||
t.Run("Patch with valid indices", func(t *testing.T) {
|
||||
// Select only file 0 and 2 (c.txt and a.txt)
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{0, 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch failed: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 2}) {
|
||||
t.Errorf("Expected SelectFiles [0, 2], got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Size should be recalculated: c.txt (98501754) + a.txt (78114) = 98579868
|
||||
expectedSize := int64(98501754 + 78114)
|
||||
if meta.Res.Size != expectedSize {
|
||||
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with invalid indices are silently ignored", func(t *testing.T) {
|
||||
// Mix of valid (0, 1) and invalid (-1, 5, 100) indices
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{-1, 0, 5, 1, 100},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch should not return error for invalid indices: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
// Only valid indices 0 and 1 should remain
|
||||
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 1}) {
|
||||
t.Errorf("Expected SelectFiles [0, 1], got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Size should be: c.txt (98501754) + b.txt (8904996) = 107406750
|
||||
expectedSize := int64(98501754 + 8904996)
|
||||
if meta.Res.Size != expectedSize {
|
||||
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with all invalid indices results in empty selection", func(t *testing.T) {
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{-5, 10, 999},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch should not return error: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
if len(meta.Opts.SelectFiles) != 0 {
|
||||
t.Errorf("Expected empty SelectFiles, got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Note: CalcSize with empty selectFiles calculates total size of all files
|
||||
// This is by design - empty selection in CalcSize means "all files"
|
||||
// But SelectFiles being empty means no files are selected for download
|
||||
if meta.Res.Size != totalSize {
|
||||
t.Errorf("Expected size %d (CalcSize with empty slice = all files), got %d", totalSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with nil opts does nothing", func(t *testing.T) {
|
||||
// First set a known state
|
||||
f.Patch(nil, &base.Options{SelectFiles: []int{1}})
|
||||
prevSelectFiles := f.Meta().Opts.SelectFiles
|
||||
|
||||
// Patch with nil opts
|
||||
err := f.Patch(nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Patch with nil opts should not fail: %v", err)
|
||||
}
|
||||
|
||||
// Should remain unchanged
|
||||
if !reflect.DeepEqual(f.Meta().Opts.SelectFiles, prevSelectFiles) {
|
||||
t.Errorf("SelectFiles should remain unchanged after nil opts Patch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch progress array is resized", func(t *testing.T) {
|
||||
btFetcher := f.(*Fetcher)
|
||||
// Initialize progress array
|
||||
btFetcher.data.Progress = make(fetcher.Progress, 3)
|
||||
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{0, 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch failed: %v", err)
|
||||
}
|
||||
|
||||
if len(btFetcher.data.Progress) != 2 {
|
||||
t.Errorf("Expected Progress length 2, got %d", len(btFetcher.data.Progress))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// modify from github.com/anacrolix/torrent/storage/file.go
|
||||
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/anacrolix/missinggo/v2"
|
||||
|
||||
"github.com/anacrolix/torrent/common"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/segments"
|
||||
)
|
||||
|
||||
// File-based storage for torrents, that isn't yet bound to a particular torrent.
|
||||
type fileClientImpl struct {
|
||||
opts newFileClientOpts
|
||||
}
|
||||
|
||||
type newFileClientOpts struct {
|
||||
// The base directory for all downloads.
|
||||
ClientBaseDir string
|
||||
HandleFileTorrent func(infoHash metainfo.Hash, ft *fileTorrentImpl)
|
||||
FilePathMaker storage.FilePathMaker
|
||||
PieceCompletion storage.PieceCompletion
|
||||
}
|
||||
|
||||
func newFileOpts(opts newFileClientOpts) storage.ClientImplCloser {
|
||||
if opts.FilePathMaker == nil {
|
||||
opts.FilePathMaker = func(opts storage.FilePathMakerOpts) string {
|
||||
var parts []string
|
||||
if opts.Info.Name != metainfo.NoName {
|
||||
parts = append(parts, opts.Info.Name)
|
||||
}
|
||||
return filepath.Join(append(parts, opts.File.Path...)...)
|
||||
}
|
||||
}
|
||||
if opts.PieceCompletion == nil {
|
||||
ret, err := storage.NewDefaultPieceCompletionForDir(opts.ClientBaseDir)
|
||||
if err != nil {
|
||||
ret = storage.NewMapPieceCompletion()
|
||||
}
|
||||
opts.PieceCompletion = ret
|
||||
}
|
||||
return fileClientImpl{opts}
|
||||
}
|
||||
|
||||
func (fs fileClientImpl) Close() error {
|
||||
return fs.opts.PieceCompletion.Close()
|
||||
}
|
||||
|
||||
func (fs fileClientImpl) OpenTorrent(ctx context.Context, info *metainfo.Info, infoHash metainfo.Hash) (_ storage.TorrentImpl, err error) {
|
||||
upvertedFiles := info.UpvertedFiles()
|
||||
files := make([]file, 0, len(upvertedFiles))
|
||||
for _, fileInfo := range upvertedFiles {
|
||||
filePath := filepath.Join("", fs.opts.FilePathMaker(storage.FilePathMakerOpts{
|
||||
Info: info,
|
||||
File: &fileInfo,
|
||||
}))
|
||||
f := file{
|
||||
rawPath: filePath,
|
||||
path: filePath,
|
||||
length: fileInfo.Length,
|
||||
}
|
||||
if f.length == 0 {
|
||||
err = CreateNativeZeroLengthFile(f.path)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("creating zero length file: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
t := &fileTorrentImpl{
|
||||
files,
|
||||
segments.NewIndex(common.LengthIterFromUpvertedFiles(upvertedFiles)),
|
||||
infoHash,
|
||||
fs.opts.PieceCompletion,
|
||||
}
|
||||
fs.opts.HandleFileTorrent(infoHash, t)
|
||||
return storage.TorrentImpl{
|
||||
Piece: t.Piece,
|
||||
Close: t.Close,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type file struct {
|
||||
// The safe, OS-local file path.
|
||||
rawPath string
|
||||
path string
|
||||
length int64
|
||||
}
|
||||
|
||||
type fileTorrentImpl struct {
|
||||
files []file
|
||||
segmentLocater segments.Index
|
||||
infoHash metainfo.Hash
|
||||
completion storage.PieceCompletion
|
||||
}
|
||||
|
||||
func (fts *fileTorrentImpl) Piece(p metainfo.Piece) storage.PieceImpl {
|
||||
// Create a view onto the file-based torrent storage.
|
||||
_io := fileTorrentImplIO{fts}
|
||||
// Return the appropriate segments of this.
|
||||
return &filePieceImpl{
|
||||
fts,
|
||||
p,
|
||||
missinggo.NewSectionWriter(_io, p.Offset(), p.Length()),
|
||||
io.NewSectionReader(_io, p.Offset(), p.Length()),
|
||||
}
|
||||
}
|
||||
|
||||
func (fts *fileTorrentImpl) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fts *fileTorrentImpl) setTorrentDir(dir string) {
|
||||
for i, f := range fts.files {
|
||||
fts.files[i].path = filepath.Join(dir, f.rawPath)
|
||||
}
|
||||
}
|
||||
|
||||
// A helper to create zero-length files which won't appear for file-orientated storage since no
|
||||
// writes will ever occur to them (no torrent data is associated with a zero-length file). The
|
||||
// caller should make sure the file name provided is safe/sanitized.
|
||||
func CreateNativeZeroLengthFile(name string) error {
|
||||
os.MkdirAll(filepath.Dir(name), 0o777)
|
||||
var f io.Closer
|
||||
f, err := os.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// Exposes file-based storage of a torrent, as one big ReadWriterAt.
|
||||
type fileTorrentImplIO struct {
|
||||
fts *fileTorrentImpl
|
||||
}
|
||||
|
||||
// Returns EOF on short or missing file.
|
||||
func (fst *fileTorrentImplIO) readFileAt(file file, b []byte, off int64) (n int, err error) {
|
||||
f, err := os.Open(file.path)
|
||||
if os.IsNotExist(err) {
|
||||
// File missing is treated the same as a short file.
|
||||
err = io.EOF
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
// Limit the read to within the expected bounds of this file.
|
||||
if int64(len(b)) > file.length-off {
|
||||
b = b[:file.length-off]
|
||||
}
|
||||
for off < file.length && len(b) != 0 {
|
||||
n1, err1 := f.ReadAt(b, off)
|
||||
b = b[n1:]
|
||||
n += n1
|
||||
off += int64(n1)
|
||||
if n1 == 0 {
|
||||
err = err1
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Only returns EOF at the end of the torrent. Premature EOF is ErrUnexpectedEOF.
|
||||
func (fst fileTorrentImplIO) ReadAt(b []byte, off int64) (n int, err error) {
|
||||
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(b))}, func(i int, e segments.Extent) bool {
|
||||
n1, err1 := fst.readFileAt(fst.fts.files[i], b[:e.Length], e.Start)
|
||||
n += n1
|
||||
b = b[n1:]
|
||||
err = err1
|
||||
return err == nil // && int64(n1) == e.Length
|
||||
})
|
||||
if len(b) != 0 && err == nil {
|
||||
err = io.EOF
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (fst fileTorrentImplIO) WriteAt(p []byte, off int64) (n int, err error) {
|
||||
// log.Printf("write at %v: %v bytes", off, len(p))
|
||||
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(p))}, func(i int, e segments.Extent) bool {
|
||||
name := fst.fts.files[i].path
|
||||
os.MkdirAll(filepath.Dir(name), 0o777)
|
||||
var f *os.File
|
||||
f, err = os.OpenFile(name, os.O_WRONLY|os.O_CREATE, 0o666)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var n1 int
|
||||
n1, err = f.WriteAt(p[:e.Length], e.Start)
|
||||
// log.Printf("%v %v wrote %v: %v", i, e, n1, err)
|
||||
closeErr := f.Close()
|
||||
n += n1
|
||||
p = p[n1:]
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err == nil && int64(n1) != e.Length {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
return err == nil
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// modify from github.com/anacrolix/torrent/storage/file_misc.go
|
||||
|
||||
package bt
|
||||
|
||||
import "github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
type requiredLength struct {
|
||||
fileIndex int
|
||||
length int64
|
||||
}
|
||||
|
||||
func extentCompleteRequiredLengths(info *metainfo.Info, off, n int64) (ret []requiredLength) {
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
for i, fi := range info.UpvertedFiles() {
|
||||
if off >= fi.Length {
|
||||
off -= fi.Length
|
||||
continue
|
||||
}
|
||||
n1 := n
|
||||
if off+n1 > fi.Length {
|
||||
n1 = fi.Length - off
|
||||
}
|
||||
ret = append(ret, requiredLength{
|
||||
fileIndex: i,
|
||||
length: off + n1,
|
||||
})
|
||||
n -= n1
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
off = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// modify from github.com/anacrolix/torrent/storage/file_piece.go
|
||||
|
||||
package bt
|
||||
|
||||
import (
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type filePieceImpl struct {
|
||||
*fileTorrentImpl
|
||||
p metainfo.Piece
|
||||
io.WriterAt
|
||||
io.ReaderAt
|
||||
}
|
||||
|
||||
var _ storage.PieceImpl = (*filePieceImpl)(nil)
|
||||
|
||||
func (fs *filePieceImpl) pieceKey() metainfo.PieceKey {
|
||||
return metainfo.PieceKey{InfoHash: fs.infoHash, Index: fs.p.Index()}
|
||||
}
|
||||
|
||||
func (fs *filePieceImpl) Completion() storage.Completion {
|
||||
c, err := fs.completion.Get(fs.pieceKey())
|
||||
if err != nil {
|
||||
log.Printf("error getting piece completion: %s", err)
|
||||
c.Ok = false
|
||||
return c
|
||||
}
|
||||
|
||||
verified := true
|
||||
if c.Complete {
|
||||
// If it's allegedly complete, check that its constituent files have the necessary length.
|
||||
for _, fi := range extentCompleteRequiredLengths(fs.p.Info, fs.p.Offset(), fs.p.Length()) {
|
||||
s, err := os.Stat(fs.files[fi.fileIndex].path)
|
||||
if err != nil || s.Size() < fi.length {
|
||||
verified = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !verified {
|
||||
// The completion was wrong, fix it.
|
||||
c.Complete = false
|
||||
fs.completion.Set(fs.pieceKey(), false)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (fs *filePieceImpl) MarkComplete() error {
|
||||
return fs.completion.Set(fs.pieceKey(), true)
|
||||
}
|
||||
|
||||
func (fs *filePieceImpl) MarkNotComplete() error {
|
||||
return fs.completion.Set(fs.pieceKey(), false)
|
||||
}
|
||||
+1561
-426
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Fetcher Data (for Store/Restore)
|
||||
// ============================================================================
|
||||
|
||||
type fetcherData struct {
|
||||
Connections []*connection
|
||||
RedirectURL string // Saved redirect URL for resume
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fetcher Manager
|
||||
// ============================================================================
|
||||
|
||||
type FetcherManager struct {
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Name() string {
|
||||
return "http"
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||
return []*fetcher.SchemeFilter{
|
||||
{
|
||||
Type: fetcher.FilterTypeUrl,
|
||||
Pattern: "HTTP",
|
||||
},
|
||||
{
|
||||
Type: fetcher.FilterTypeUrl,
|
||||
Pattern: "HTTPS",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||
return &Fetcher{}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) ParseName(u string) string {
|
||||
var name string
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name = path.Base(url.Path)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
name = url.Hostname()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) AutoRename() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
|
||||
Connections: 16,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
_f.redirectLock.Lock()
|
||||
redirectURL := _f.redirectURL
|
||||
_f.redirectLock.Unlock()
|
||||
return &fetcherData{
|
||||
Connections: _f.connections,
|
||||
RedirectURL: redirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||
fd := v.(*fetcherData)
|
||||
fb := &FetcherManager{}
|
||||
fetcher := fb.Build().(*Fetcher)
|
||||
fetcher.meta = meta
|
||||
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
|
||||
base.ParseOptExtra[fhttp.OptsExtra](fetcher.meta.Opts)
|
||||
if len(fd.Connections) > 0 {
|
||||
fetcher.connections = fd.Connections
|
||||
}
|
||||
// Restore redirect URL for resume
|
||||
if fd.RedirectURL != "" {
|
||||
fetcher.redirectURL = fd.RedirectURL
|
||||
}
|
||||
return fetcher
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,138 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseFilenameWithAmpersand tests the fix for filenames containing & character
|
||||
// Issue: filenames with & are HTML-encoded as & and then truncated at the semicolon
|
||||
func TestParseFilenameWithAmpersand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
disposition string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "quoted filename with &",
|
||||
disposition: `attachment; filename="查询处理&优化.pptx"`,
|
||||
want: "查询处理&优化.pptx",
|
||||
},
|
||||
{
|
||||
name: "unquoted filename with &",
|
||||
disposition: `attachment; filename=test&file.txt`,
|
||||
want: "test&file.txt",
|
||||
},
|
||||
{
|
||||
name: "quoted filename with & and extra params",
|
||||
disposition: `attachment; filename="test&file.txt"; charset=utf-8`,
|
||||
want: "test&file.txt",
|
||||
},
|
||||
{
|
||||
name: "filename with multiple HTML entities",
|
||||
disposition: `attachment; filename="test&<>.txt"`,
|
||||
want: "test&<>.txt",
|
||||
},
|
||||
{
|
||||
name: "normal filename without entities",
|
||||
disposition: `attachment; filename="normal.txt"`,
|
||||
want: "normal.txt",
|
||||
},
|
||||
{
|
||||
name: "filename with actual ampersand (no encoding)",
|
||||
disposition: `attachment; filename="test&file.txt"`,
|
||||
want: "test&file.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFilename(tt.disposition)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseFilename() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindParamValueEnd tests the helper function for finding parameter value boundaries
|
||||
func TestFindParamValueEnd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "quoted value with semicolon inside",
|
||||
value: `"test&file.txt"; charset=utf-8`,
|
||||
want: 19, // Position of semicolon after closing quote
|
||||
},
|
||||
{
|
||||
name: "quoted value without semicolon after",
|
||||
value: `"test.txt"`,
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "unquoted value with semicolon",
|
||||
value: `test.txt; charset=utf-8`,
|
||||
want: 8,
|
||||
},
|
||||
{
|
||||
name: "unquoted value without semicolon",
|
||||
value: `test.txt`,
|
||||
want: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := findParamValueEnd(tt.value)
|
||||
if got != tt.want {
|
||||
t.Errorf("findParamValueEnd() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnescapeHTMLEntities tests HTML entity unescaping
|
||||
func TestUnescapeHTMLEntities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ampersand",
|
||||
in: "test&file.txt",
|
||||
want: "test&file.txt",
|
||||
},
|
||||
{
|
||||
name: "less than and greater than",
|
||||
in: "<test>.txt",
|
||||
want: "<test>.txt",
|
||||
},
|
||||
{
|
||||
name: "quote",
|
||||
in: "test"file.txt",
|
||||
want: "test\"file.txt",
|
||||
},
|
||||
{
|
||||
name: "multiple entities",
|
||||
in: "a&b<c>d"e",
|
||||
want: "a&b<c>d\"e",
|
||||
},
|
||||
{
|
||||
name: "no entities",
|
||||
in: "normal.txt",
|
||||
want: "normal.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := unescapeHTMLEntities(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("unescapeHTMLEntities() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
type RequestError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func NewRequestError(code int) *RequestError {
|
||||
return &RequestError{Code: code}
|
||||
}
|
||||
|
||||
func (re *RequestError) Error() string {
|
||||
return fmt.Sprintf("http request fail, code:%d", re.Code)
|
||||
}
|
||||
|
||||
func isFailureExemptHTTPCode(code int) bool {
|
||||
if code >= 500 && code <= 599 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch code {
|
||||
case 429, 408, 440, 499:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldCountHTTPFailure(err error) bool {
|
||||
var re *RequestError
|
||||
if !errors.As(err, &re) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !isFailureExemptHTTPCode(re.Code)
|
||||
}
|
||||
|
||||
func extractRequestError(err error) *RequestError {
|
||||
var re *RequestError
|
||||
if errors.As(err, &re) {
|
||||
return re
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRequest creates an HTTP request using the redirect URL if available.
|
||||
func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||
return f.buildRequestWithURL(ctx, req, true)
|
||||
}
|
||||
|
||||
// buildRequestWithOriginalURL creates an HTTP request using the original URL.
|
||||
// This is used for retrying when the redirect URL has expired.
|
||||
func (f *Fetcher) buildRequestWithOriginalURL(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||
return f.buildRequestWithURL(ctx, req, false)
|
||||
}
|
||||
|
||||
// buildRequestWithURL creates an HTTP request.
|
||||
// If useRedirect is true and a redirect URL exists, it will be used; otherwise the original URL is used.
|
||||
func (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, useRedirect bool) (httpReq *http.Request, err error) {
|
||||
var reqUrl string
|
||||
f.redirectLock.Lock()
|
||||
if useRedirect && f.redirectURL != "" {
|
||||
reqUrl = f.redirectURL
|
||||
} else {
|
||||
reqUrl = req.URL
|
||||
}
|
||||
f.redirectLock.Unlock()
|
||||
|
||||
var (
|
||||
method string
|
||||
body io.Reader
|
||||
)
|
||||
headers := http.Header{}
|
||||
if req.Extra == nil {
|
||||
method = http.MethodGet
|
||||
} else {
|
||||
extra := req.Extra.(*fhttp.ReqExtra)
|
||||
if extra.Method != "" {
|
||||
method = extra.Method
|
||||
} else {
|
||||
method = http.MethodGet
|
||||
}
|
||||
if len(extra.Header) > 0 {
|
||||
for k, v := range extra.Header {
|
||||
headers.Set(k, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
if extra.Body != "" {
|
||||
body = bytes.NewBufferString(extra.Body)
|
||||
}
|
||||
}
|
||||
if _, ok := headers[base.HttpHeaderUserAgent]; !ok {
|
||||
headers.Set(base.HttpHeaderUserAgent, strings.TrimSpace(f.config.UserAgent))
|
||||
}
|
||||
|
||||
if ctx != nil {
|
||||
httpReq, err = http.NewRequestWithContext(ctx, method, reqUrl, body)
|
||||
} else {
|
||||
httpReq, err = http.NewRequest(method, reqUrl, body)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
httpReq.Header = headers
|
||||
if host := headers.Get(base.HttpHeaderHost); host != "" {
|
||||
httpReq.Host = host
|
||||
}
|
||||
return httpReq, nil
|
||||
}
|
||||
|
||||
// updateRedirectURL updates the redirect URL from the response.
|
||||
// This is called when a request using the original URL succeeds after the redirect URL expired.
|
||||
func (f *Fetcher) updateRedirectURL(url string) {
|
||||
f.redirectLock.Lock()
|
||||
f.redirectURL = url
|
||||
f.redirectLock.Unlock()
|
||||
}
|
||||
|
||||
// hasRedirectURL checks if a redirect URL exists and is different from the original URL.
|
||||
func (f *Fetcher) hasRedirectURL() bool {
|
||||
f.redirectLock.Lock()
|
||||
defer f.redirectLock.Unlock()
|
||||
return f.redirectURL != "" && f.redirectURL != f.meta.Req.URL
|
||||
}
|
||||
|
||||
// isRedirectExpiredError checks if the error indicates that the redirect URL may have expired.
|
||||
// This includes 403 (Forbidden), 401 (Unauthorized), 410 (Gone), and network errors.
|
||||
func isRedirectExpiredError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for specific HTTP error codes that might indicate URL expiration
|
||||
if re := extractRequestError(err); re != nil {
|
||||
switch re.Code {
|
||||
case 401, 403, 404, 410:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// tryFallbackToOriginalURL attempts to make a request using the original URL
|
||||
// when the redirect URL has expired. Returns the response if successful.
|
||||
func (f *Fetcher) tryFallbackToOriginalURL(ctx context.Context, client *http.Client, rangeStart, rangeEnd int64) (*http.Response, error) {
|
||||
httpReq, err := f.buildRequestWithOriginalURL(ctx, f.meta.Req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if f.meta.Res.Range && rangeEnd > 0 {
|
||||
httpReq.Header.Set(base.HttpHeaderRange,
|
||||
fmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd))
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
|
||||
resp.Body.Close()
|
||||
return nil, NewRequestError(resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildClient creates an HTTP client with the default connection timeout.
|
||||
// Used for resolve phase where we don't have connection time data yet.
|
||||
func (f *Fetcher) buildClient() *http.Client {
|
||||
return f.buildClientWithTimeout(connectTimeout)
|
||||
}
|
||||
|
||||
// buildFastFailClient creates an HTTP client with fast-fail timeout.
|
||||
// Uses max(minFastFailTimeout, maxConnTime) for fast-fail retry during download phase.
|
||||
func (f *Fetcher) buildFastFailClient() *http.Client {
|
||||
maxConn := f.maxConnTime.Load()
|
||||
if maxConn == 0 {
|
||||
// No successful connection yet, use default timeout
|
||||
return f.buildClientWithTimeout(connectTimeout)
|
||||
}
|
||||
|
||||
timeout := maxConn
|
||||
if timeout >= minFastFailTimeout {
|
||||
// If greater than minFastFailTimeout, increase by 50% for safety margin
|
||||
timeout = int64(float64(timeout) * 1.5)
|
||||
} else {
|
||||
timeout = minFastFailTimeout
|
||||
}
|
||||
return f.buildClientWithTimeout(time.Duration(timeout))
|
||||
}
|
||||
|
||||
// buildClientWithTimeout creates an HTTP client with the specified connection timeout.
|
||||
func (f *Fetcher) buildClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
}).DialContext,
|
||||
Proxy: f.ctl.GetProxy(f.meta.Req.Proxy),
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: f.meta.Req.SkipVerifyCert,
|
||||
},
|
||||
TLSHandshakeTimeout: timeout,
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Jar: jar,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filename Parsing
|
||||
// ============================================================================
|
||||
|
||||
// parseFilename extracts filename from Content-Disposition header
|
||||
func parseFilename(contentDisposition string) string {
|
||||
// Try RFC 5987 extended notation first (filename*=)
|
||||
if filename := parseFilenameExtended(contentDisposition); filename != "" {
|
||||
return filename
|
||||
}
|
||||
|
||||
// Try standard MIME parsing
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err == nil {
|
||||
if filename := params["filename"]; filename != "" {
|
||||
return decodeFilenameParam(filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to manual parsing
|
||||
return parseFilenameFallback(contentDisposition)
|
||||
}
|
||||
|
||||
// parseFilenameExtended handles RFC 5987 extended notation (filename*=)
|
||||
// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)
|
||||
func parseFilenameExtended(cd string) string {
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename*=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
value := cd[idx+len("filename*="):]
|
||||
|
||||
// Find the end of the value using proper quote handling
|
||||
endIdx := findParamValueEnd(value)
|
||||
if endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Try charset''encoded format (e.g., UTF-8''%E4%B8%AD%E6%96%87.txt)
|
||||
parts := strings.SplitN(value, "''", 2)
|
||||
if len(parts) == 2 {
|
||||
// Use PathUnescape to handle %2B correctly (should decode to +, not space)
|
||||
decoded, err := url.PathUnescape(parts[1])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try charset'language'encoded format
|
||||
parts = strings.SplitN(value, "'", 3)
|
||||
if len(parts) >= 3 {
|
||||
// Use PathUnescape to handle %2B correctly (should decode to +, not space)
|
||||
decoded, err := url.PathUnescape(parts[2])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// decodeFilenameParam decodes filename parameter value
|
||||
// Handles HTML entities, MIME encoded-word, URL encoding, and GBK encoding fallback
|
||||
func decodeFilenameParam(filename string) string {
|
||||
// First, unescape HTML entities (e.g., & -> &, < -> <, > -> >)
|
||||
// This must be done before other decoding to handle cases where servers
|
||||
// HTML-encode special characters in filenames
|
||||
filename = unescapeHTMLEntities(filename)
|
||||
|
||||
// Handle RFC 2047 encoded word (=?charset?encoding?text?=)
|
||||
if strings.HasPrefix(filename, "=?") {
|
||||
decoder := new(mime.WordDecoder)
|
||||
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
|
||||
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try URL decoding - use PathUnescape for filenames to handle %2B correctly
|
||||
decoded := util.TryUrlPathUnescape(filename)
|
||||
|
||||
// If not valid UTF-8, try GBK decoding (common for Chinese websites)
|
||||
if !utf8.ValidString(decoded) {
|
||||
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
|
||||
return gbkDecoded
|
||||
}
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// unescapeHTMLEntities unescapes common HTML entities in filenames
|
||||
// This handles cases where servers HTML-encode special characters like & to &
|
||||
func unescapeHTMLEntities(s string) string {
|
||||
// Common HTML entities that might appear in filenames
|
||||
replacements := map[string]string{
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": "\"",
|
||||
"'": "'",
|
||||
"'": "'",
|
||||
}
|
||||
|
||||
result := s
|
||||
for entity, char := range replacements {
|
||||
result = strings.ReplaceAll(result, entity, char)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// tryDecodeGBK attempts to decode string as GBK encoding
|
||||
func tryDecodeGBK(s string) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
decoder := simplifiedchinese.GBK.NewDecoder()
|
||||
decoded, err := decoder.Bytes([]byte(s))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
result := string(decoded)
|
||||
if utf8.ValidString(result) {
|
||||
return result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseFilenameFallback is a fallback parser for non-standard Content-Disposition
|
||||
func parseFilenameFallback(cd string) string {
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
value := cd[idx+len("filename="):]
|
||||
|
||||
// Find the end of the value using proper quote handling
|
||||
endIdx := findParamValueEnd(value)
|
||||
if endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Remove surrounding quotes
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return decodeFilenameParam(value)
|
||||
}
|
||||
|
||||
// findParamValueEnd finds the end position of a parameter value in a Content-Disposition header.
|
||||
// It correctly handles quoted values where semicolons inside quotes should not be treated as delimiters.
|
||||
// It also handles HTML entities in unquoted values (e.g., & should not be split at the semicolon).
|
||||
// Returns the end index (exclusive) of the value, or -1 if it extends to the end of the string.
|
||||
func findParamValueEnd(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If the value starts with a quote, find the matching closing quote
|
||||
if value[0] == '"' || value[0] == '\'' {
|
||||
quote := value[0]
|
||||
// Find the closing quote, handling escaped quotes
|
||||
for i := 1; i < len(value); i++ {
|
||||
if value[i] == quote {
|
||||
// Check if it's escaped
|
||||
if i > 0 && value[i-1] == '\\' {
|
||||
continue
|
||||
}
|
||||
// Found closing quote, now look for ; after it
|
||||
remaining := value[i+1:]
|
||||
if semiIdx := strings.Index(remaining, ";"); semiIdx != -1 {
|
||||
return i + 1 + semiIdx
|
||||
}
|
||||
return -1 // No semicolon after closing quote
|
||||
}
|
||||
}
|
||||
// No closing quote found, treat rest of string as value
|
||||
return -1
|
||||
}
|
||||
|
||||
// Unquoted value - find the next semicolon that's not part of an HTML entity
|
||||
// HTML entities have the pattern &...; (e.g., & < > " ')
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == ';' {
|
||||
// Check if this semicolon is part of an HTML entity
|
||||
// Look backwards for an & character
|
||||
isEntity := false
|
||||
if i > 0 {
|
||||
// Look for & before this semicolon (within reasonable distance, max 10 chars)
|
||||
for j := i - 1; j >= 0 && j >= i-10; j-- {
|
||||
if value[j] == '&' {
|
||||
// Found &, this semicolon might be part of an HTML entity
|
||||
// Check if there are only alphanumeric or # between & and ;
|
||||
entityChars := value[j+1 : i]
|
||||
if len(entityChars) > 0 && isValidHTMLEntityChars(entityChars) {
|
||||
isEntity = true
|
||||
}
|
||||
break
|
||||
}
|
||||
// If we hit whitespace or another special char, stop looking
|
||||
if value[j] == ' ' || value[j] == '"' || value[j] == '\'' {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isEntity {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1 // No semicolon, extends to end
|
||||
}
|
||||
|
||||
// isValidHTMLEntityChars checks if a string contains only valid HTML entity characters
|
||||
// (alphanumeric and #, typically for entities like & < ' etc.)
|
||||
func isValidHTMLEntityChars(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '#') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+545
-16
@@ -6,8 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/armon/go-socks5"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,6 +15,10 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/armon/go-socks5"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +36,10 @@ const (
|
||||
DownloadRename = "download (1).data"
|
||||
DownloadFile = Dir + DownloadName
|
||||
DownloadRenameFile = Dir + DownloadRename
|
||||
|
||||
// TestChineseFileName is a common test filename with Chinese characters
|
||||
// Used to test Content-Disposition parsing with various encodings
|
||||
TestChineseFileName = "测试.zip"
|
||||
)
|
||||
|
||||
func StartTestFileServer() net.Listener {
|
||||
@@ -64,6 +70,9 @@ func StartTestSlowFileServer(delay time.Duration) net.Listener {
|
||||
func StartTestCustomServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(200)
|
||||
})
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
@@ -102,6 +111,142 @@ func StartTestCustomServer() net.Listener {
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
|
||||
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
|
||||
// due to invalid characters like <a> tags in the filename.
|
||||
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// This simulates a server that sends a garbled filename= with special chars that cause
|
||||
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
|
||||
// The filename*= should be preferred and correctly parsed.
|
||||
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for filename*= only (RFC 5987 format)
|
||||
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
|
||||
// This simulates the case where Chinese characters are sent as GBK bytes
|
||||
// which appear as garbled characters when interpreted as UTF-8.
|
||||
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
|
||||
// Our fix detects invalid UTF-8 and attempts GBK decoding.
|
||||
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Encode TestChineseFileName as GBK
|
||||
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
|
||||
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
|
||||
// Send GBK bytes directly in filename (simulating broken server behavior)
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for filenames with plus signs (C++ files, etc.)
|
||||
// This tests that %2B decodes to + not space
|
||||
mux.HandleFunc("/plus-sign-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Use filename*= format with %2B encoding for plus signs
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''C%2B%2B%20%20Primer%20%20Plus.mobi`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for plus sign in URL path
|
||||
mux.HandleFunc("/C%2B%2B%20Primer.txt", func(writer http.ResponseWriter, request *http.Request) {
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for filename with HTML-encoded ampersand (&)
|
||||
// This tests the case from the bug report where filenames containing & are
|
||||
// HTML-encoded as & by the server, causing truncation at the semicolon.
|
||||
// Example: "查询处理&优化.pptx" -> "查询处理&优化.pptx"
|
||||
mux.HandleFunc("/ampersand-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Simulate server sending filename with HTML-encoded ampersand
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="查询处理&优化.pptx"`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for unquoted filename with HTML-encoded ampersand
|
||||
// Some servers might send unquoted filenames with HTML entities
|
||||
mux.HandleFunc("/ampersand-unquoted", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename=test&file.txt`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test 403 Forbidden endpoint
|
||||
mux.HandleFunc("/forbidden", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(403)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestHostHeaderServer starts a server that validates the Host header
|
||||
// Returns 400 Bad Request if the Host header value equals "test"
|
||||
func StartTestHostHeaderServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// If the Host header is "test", return 400 (simulating server that validates Host)
|
||||
if request.Host == "test" {
|
||||
writer.WriteHeader(400)
|
||||
writer.Write([]byte("Bad Request: Invalid Host header"))
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(200)
|
||||
writer.Write([]byte("OK"))
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
@@ -151,23 +296,161 @@ func StartTestPostServer() net.Listener {
|
||||
}
|
||||
|
||||
func StartTestErrorServer() net.Listener {
|
||||
counter := 0
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
counter++
|
||||
if counter != 1 {
|
||||
writer.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
// Always return 404 error to test error handling
|
||||
writer.WriteHeader(404)
|
||||
return
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestOneTimeServer creates a server where the URL can only be downloaded once
|
||||
// The first non-probe request succeeds, all subsequent requests return 404
|
||||
// This simulates one-time download URLs (e.g., signed URLs that expire after first use)
|
||||
func StartTestOneTimeServer() net.Listener {
|
||||
var accessed atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
// First full download request succeeds, subsequent requests fail
|
||||
if accessed.Swap(true) {
|
||||
writer.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
|
||||
// First request - return full file without Range support
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.
|
||||
// The original URL redirects to a temporary URL that expires after a specified number of requests.
|
||||
// When the temporary URL expires (returns 403), the client should retry with the original URL
|
||||
// to get a new redirect URL.
|
||||
// Parameters:
|
||||
// - requestsBeforeExpire: number of requests the temporary URL accepts before expiring
|
||||
// - delayPerByte: optional delay per byte for slow transfer (use 0 for no delay)
|
||||
func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte time.Duration) net.Listener {
|
||||
var redirectVersion atomic.Int32
|
||||
var requestCount atomic.Int32
|
||||
redirectVersion.Store(1)
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
path := request.URL.Path
|
||||
|
||||
// Original URL - redirects to current version of temporary URL
|
||||
if path == "/"+BuildName {
|
||||
// Reset request count when original URL is accessed
|
||||
requestCount.Store(0)
|
||||
// Redirect to versioned temporary URL
|
||||
version := redirectVersion.Load()
|
||||
redirectURL := fmt.Sprintf("/redirect-v%d/%s", version, BuildName)
|
||||
http.Redirect(writer, request, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Temporary URL handler - matches /redirect-v{N}/... pattern
|
||||
if strings.HasPrefix(path, "/redirect-v") {
|
||||
// Check if the redirect has expired
|
||||
count := requestCount.Add(1)
|
||||
if count > requestsBeforeExpire {
|
||||
// Redirect expired - increment version for next redirect
|
||||
redirectVersion.Add(1)
|
||||
writer.WriteHeader(403)
|
||||
writer.Write([]byte("Redirect URL expired"))
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file with range support
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if delayPerByte > 0 {
|
||||
slowCopyNWithDelay(sl, writer, file, n, delayPerByte)
|
||||
} else {
|
||||
io.CopyN(writer, file, n)
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Not found
|
||||
writer.WriteHeader(404)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
||||
// This allows testing slow-start connection expansion to reach max connections
|
||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyNWithDelay(sl, writer, file, n, delayPerByte)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// slowCopyNWithDelay copies n bytes from src to dst with a delay per byte
|
||||
func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delayPerByte time.Duration) {
|
||||
buf := make([]byte, 32*1024)
|
||||
remaining := n
|
||||
for remaining > 0 {
|
||||
if sl.isShutdown {
|
||||
return
|
||||
}
|
||||
toRead := int64(len(buf))
|
||||
if toRead > remaining {
|
||||
toRead = remaining
|
||||
}
|
||||
nr, er := src.Read(buf[:toRead])
|
||||
if nr > 0 {
|
||||
nw, ew := dst.Write(buf[0:nr])
|
||||
if nw > 0 {
|
||||
remaining -= int64(nw)
|
||||
// Add delay based on bytes written
|
||||
if delayPerByte > 0 {
|
||||
time.Sleep(delayPerByte * time.Duration(nw))
|
||||
}
|
||||
}
|
||||
if ew != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartTestLimitServer connections limit server
|
||||
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
var connections atomic.Int32
|
||||
var slowOnce atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
@@ -180,12 +463,144 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
writer.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
|
||||
// First request intentionally delays the first write to trigger a single read timeout,
|
||||
// subsequent requests respond at normal speed.
|
||||
useInitialDelay := delay > 0 && !slowOnce.Swap(true)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyN(sl, writer, file, n, delay)
|
||||
if useInitialDelay {
|
||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
||||
return
|
||||
}
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestTimeoutOnceServer creates a server that times out on first request, then works normally
|
||||
func StartTestTimeoutOnceServer(delay int64) net.Listener {
|
||||
var timeoutOnce atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
// First request delays to trigger timeout, subsequent requests respond normally
|
||||
useInitialDelay := delay > 0 && !timeoutOnce.Swap(true)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if useInitialDelay {
|
||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
||||
return
|
||||
}
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestTemporary500Server creates a server that returns 500 for a duration, then recovers
|
||||
// Uses slow transfer to ensure the file isn't fully downloaded during resolve phase
|
||||
func StartTestTemporary500Server(errorDuration time.Duration) net.Listener {
|
||||
startTime := time.Now()
|
||||
var requestCount atomic.Int32
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
reqNum := requestCount.Add(1)
|
||||
|
||||
// First request (Resolve) succeeds with slow transfer to prevent full download
|
||||
// Subsequent requests return 500 for the specified duration, then recover
|
||||
if reqNum == 1 {
|
||||
// Slow transfer for resolve: 50 microsecond per byte (~20MB/s)
|
||||
// This ensures resolve doesn't complete the full 200MB file
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyNWithDelay(sl, writer, file, n, 50*time.Microsecond)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests: return 500 for errorDuration, then normal
|
||||
if time.Since(startTime) < errorDuration {
|
||||
writer.WriteHeader(500)
|
||||
writer.Write([]byte("Internal Server Error"))
|
||||
return
|
||||
}
|
||||
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestFailThenRecoverServer creates a server that fails all connections initially,
|
||||
// then recovers after a specified number of failed requests.
|
||||
// This tests the retry functionality when calling Start() again after a download fails.
|
||||
// Parameters:
|
||||
// - failedRequestsBeforeRecover: number of requests that will fail before server recovers
|
||||
//
|
||||
// Note: Uses 416 (Range Not Satisfiable) instead of 500 because 5xx errors are exempt
|
||||
// from failure counting and will retry indefinitely. 416 is counted as a failure.
|
||||
func StartTestFailThenRecoverServer(failedRequestsBeforeRecover int32) net.Listener {
|
||||
var requestCount atomic.Int32
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
reqNum := requestCount.Add(1)
|
||||
|
||||
// First request (Resolve) always succeeds
|
||||
if reqNum == 1 {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests fail until we've had enough failures
|
||||
// Use 416 Range Not Satisfiable - this error is counted towards failure limit
|
||||
if reqNum <= failedRequestsBeforeRecover+1 { // +1 because first request is resolve
|
||||
writer.WriteHeader(416)
|
||||
writer.Write([]byte("Range Not Satisfiable"))
|
||||
return
|
||||
}
|
||||
|
||||
// After enough failures, server recovers
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -223,6 +638,26 @@ func StartTestRangeBugServer() net.Listener {
|
||||
|
||||
func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEnd func(end int64) int64, iocpN func(file *os.File, n int64)) {
|
||||
r := request.Header.Get("Range")
|
||||
|
||||
// If no Range header, return full file with Accept-Ranges header
|
||||
if r == "" {
|
||||
// Open file first to ensure it exists
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
writer.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.WriteHeader(200)
|
||||
(writer.(http.Flusher)).Flush()
|
||||
|
||||
iocpN(file, BuildSize)
|
||||
return
|
||||
}
|
||||
|
||||
// split range
|
||||
s := strings.Split(r, "=")
|
||||
if len(s) != 2 {
|
||||
@@ -256,12 +691,7 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
|
||||
end = modifyEnd(end)
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.WriteHeader(206)
|
||||
(writer.(http.Flusher)).Flush()
|
||||
|
||||
// Open file before sending headers to ensure it exists
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
writer.WriteHeader(500)
|
||||
@@ -269,6 +699,13 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
|
||||
}
|
||||
defer file.Close()
|
||||
file.Seek(start, 0)
|
||||
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.WriteHeader(206)
|
||||
(writer.(http.Flusher)).Flush()
|
||||
|
||||
iocpN(file, end-start+1)
|
||||
}
|
||||
|
||||
@@ -319,6 +756,14 @@ func slowCopyN(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, dela
|
||||
return
|
||||
}
|
||||
|
||||
// slowCopyAfterDelay sleeps once before performing a normal copy to simulate a single timeout.
|
||||
func slowCopyAfterDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {
|
||||
if delay > 0 {
|
||||
time.Sleep(time.Millisecond * time.Duration(delay))
|
||||
}
|
||||
return slowCopyN(sl, dst, src, n, 0)
|
||||
}
|
||||
|
||||
func startTestServer(serverHandle func(sl *shutdownListener) http.Handler) net.Listener {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -363,8 +808,9 @@ type shutdownListener struct {
|
||||
}
|
||||
|
||||
func (c *shutdownListener) Close() error {
|
||||
c.isShutdown = true
|
||||
// Shutdown server first (waits for in-flight requests), then set isShutdown
|
||||
closeErr := c.server.Shutdown(context.Background())
|
||||
c.isShutdown = true
|
||||
if err := ifExistAndRemove(BuildFile); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
@@ -405,6 +851,89 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
||||
return listener
|
||||
}
|
||||
|
||||
// StartTestPatchURLServer creates a server with two endpoints:
|
||||
// - /bad-url: always returns 404 (simulates a broken download link)
|
||||
// - /good-url: returns the file successfully (simulates a working download link)
|
||||
// This is used to test the Patch functionality where a failed download URL can be
|
||||
// replaced with a working one.
|
||||
func StartTestPatchURLServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
// Bad URL - always fails with 404
|
||||
mux.HandleFunc("/bad-url", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(404)
|
||||
writer.Write([]byte("Not Found"))
|
||||
})
|
||||
// Good URL - returns the file successfully with range support
|
||||
mux.HandleFunc("/good-url", func(writer http.ResponseWriter, request *http.Request) {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestCookieExpiringServer creates a server that simulates cookie expiration during download.
|
||||
// - First request (Resolve): accepts "session=old_token" and succeeds
|
||||
// - Subsequent requests: "session=old_token" is expired (returns 401), only "session=new_token" works
|
||||
// This is used to test the Patch functionality where a cookie expires mid-download,
|
||||
// requiring the user to patch with a new cookie to resume.
|
||||
func StartTestCookieExpiringServer() net.Listener {
|
||||
var requestCount atomic.Int32
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
reqNum := requestCount.Add(1)
|
||||
cookie := request.Header.Get("Cookie")
|
||||
|
||||
// First request (Resolve): accept old_token
|
||||
if reqNum == 1 {
|
||||
if !strings.Contains(cookie, "session=old_token") {
|
||||
writer.WriteHeader(401)
|
||||
writer.Write([]byte("Unauthorized: Invalid cookie"))
|
||||
return
|
||||
}
|
||||
// Return file info for resolve (with range support)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests: old_token is expired, only new_token works
|
||||
if strings.Contains(cookie, "session=new_token") {
|
||||
// New token is valid - return the file with range support
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Old token or invalid token - return 401
|
||||
writer.WriteHeader(401)
|
||||
writer.Write([]byte("Unauthorized: Cookie expired"))
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
func AssertResourceEqual(want, got *base.Resource) bool {
|
||||
// Ignore ctime
|
||||
if got != nil && len(got.Files) > 0 {
|
||||
|
||||
+81
-11
@@ -2,13 +2,14 @@ package base
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/mattn/go-ieproxy"
|
||||
"golang.org/x/exp/slices"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/mattn/go-ieproxy"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// Request download request
|
||||
@@ -77,7 +78,7 @@ func (r *Resource) Validate() error {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("invalid resource name")
|
||||
}
|
||||
if r.Files == nil || len(r.Files) == 0 {
|
||||
if len(r.Files) == 0 {
|
||||
return fmt.Errorf("invalid resource files")
|
||||
}
|
||||
for _, file := range r.Files {
|
||||
@@ -123,7 +124,7 @@ func (o *Options) InitSelectFiles(fileSize int) {
|
||||
// if selectFiles is empty, select all files
|
||||
if len(o.SelectFiles) == 0 {
|
||||
o.SelectFiles = make([]int, fileSize)
|
||||
for i := 0; i < fileSize; i++ {
|
||||
for i := range fileSize {
|
||||
o.SelectFiles[i] = i
|
||||
}
|
||||
}
|
||||
@@ -148,7 +149,7 @@ func ParseReqExtra[E any](req *Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseOptsExtra[E any](opts *Options) error {
|
||||
func ParseOptExtra[E any](opts *Options) error {
|
||||
if opts.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -163,15 +164,30 @@ func ParseOptsExtra[E any](opts *Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type CreateTaskBatch struct {
|
||||
Reqs []*CreateTaskBatchItem `json:"reqs"`
|
||||
Opts *Options `json:"opts"`
|
||||
}
|
||||
|
||||
type CreateTaskBatchItem struct {
|
||||
Req *Request `json:"req"`
|
||||
Opts *Options `json:"opts"`
|
||||
}
|
||||
|
||||
// DownloaderStoreConfig is the config that can restore the downloader.
|
||||
type DownloaderStoreConfig struct {
|
||||
FirstLoad bool `json:"-"` // FirstLoad is the flag that the config is first time init and not from store
|
||||
|
||||
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
|
||||
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||
Extra map[string]any `json:"extra"`
|
||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
|
||||
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||
Extra map[string]any `json:"extra"`
|
||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
||||
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
||||
}
|
||||
|
||||
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
@@ -184,6 +200,24 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = &DownloaderProxyConfig{}
|
||||
}
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = &WebhookConfig{}
|
||||
}
|
||||
if cfg.Script == nil {
|
||||
cfg.Script = &ScriptConfig{}
|
||||
}
|
||||
if cfg.AutoTorrent == nil {
|
||||
cfg.AutoTorrent = &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
}
|
||||
}
|
||||
if cfg.Archive == nil {
|
||||
cfg.Archive = &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -206,9 +240,45 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = beforeCfg.Proxy
|
||||
}
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = beforeCfg.Webhook
|
||||
}
|
||||
if cfg.Script == nil {
|
||||
cfg.Script = beforeCfg.Script
|
||||
}
|
||||
if cfg.AutoTorrent == nil {
|
||||
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
||||
}
|
||||
if cfg.Archive == nil {
|
||||
cfg.Archive = beforeCfg.Archive
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WebhookConfig is the webhook configuration
|
||||
type WebhookConfig struct {
|
||||
Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks
|
||||
URLs []string `json:"urls"` // URLs is the list of webhook URLs
|
||||
}
|
||||
|
||||
// ScriptConfig is the script execution configuration
|
||||
type ScriptConfig struct {
|
||||
Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution
|
||||
Paths []string `json:"paths"` // Paths is the list of script paths to execute
|
||||
}
|
||||
|
||||
// AutoTorrentConfig is the auto torrent task creation configuration
|
||||
type AutoTorrentConfig struct {
|
||||
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
|
||||
DeleteAfterDownload bool `json:"deleteAfterDownload"` // DeleteAfterDownload deletes the .torrent file after BT task creation
|
||||
}
|
||||
|
||||
// ArchiveConfig is the archive extraction configuration
|
||||
type ArchiveConfig struct {
|
||||
AutoExtract bool `json:"autoExtract"` // AutoExtract enables automatic extraction of archives after download
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
|
||||
}
|
||||
|
||||
type DownloaderProxyConfig struct {
|
||||
Enable bool `json:"enable"`
|
||||
// System is the flag that use system proxy
|
||||
|
||||
+174
-1
@@ -18,6 +18,16 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -29,6 +39,16 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
MaxRunning: 10,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -43,7 +63,17 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -59,6 +89,64 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Init AutoTorrent",
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Init Archive",
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -71,6 +159,10 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
Script: tt.fields.Script,
|
||||
AutoTorrent: tt.fields.AutoTorrent,
|
||||
Archive: tt.fields.Archive,
|
||||
}
|
||||
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Init() = %v, want %v", got, tt.want)
|
||||
@@ -245,6 +337,84 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge AutoTorrent No Override",
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge AutoTorrent Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Archive No Override",
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
},
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Archive Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -255,6 +425,9 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
AutoTorrent: tt.fields.AutoTorrent,
|
||||
Archive: tt.fields.Archive,
|
||||
}
|
||||
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Merge() = %v, want %v", got, tt.want)
|
||||
|
||||
+641
-80
@@ -2,15 +2,7 @@ package download
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
"fmt"
|
||||
"math"
|
||||
gohttp "net/http"
|
||||
"net/url"
|
||||
@@ -22,6 +14,16 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,6 +46,24 @@ var (
|
||||
|
||||
type Listener func(event *Event)
|
||||
|
||||
// ExtractStatus represents the current status of archive extraction
|
||||
type ExtractStatus string
|
||||
|
||||
const (
|
||||
// ExtractStatusNone indicates extraction has not started
|
||||
ExtractStatusNone ExtractStatus = ""
|
||||
// ExtractStatusQueued indicates extraction is waiting in the queue
|
||||
ExtractStatusQueued ExtractStatus = "queued"
|
||||
// ExtractStatusWaitingParts indicates waiting for other multi-part archive parts to complete
|
||||
ExtractStatusWaitingParts ExtractStatus = "waitingParts"
|
||||
// ExtractStatusExtracting indicates extraction is in progress
|
||||
ExtractStatusExtracting ExtractStatus = "extracting"
|
||||
// ExtractStatusDone indicates extraction completed successfully
|
||||
ExtractStatusDone ExtractStatus = "done"
|
||||
// ExtractStatusError indicates extraction failed
|
||||
ExtractStatusError ExtractStatus = "error"
|
||||
)
|
||||
|
||||
type Progress struct {
|
||||
// Total download time(ns)
|
||||
Used int64 `json:"used"`
|
||||
@@ -55,6 +75,16 @@ type Progress struct {
|
||||
UploadSpeed int64 `json:"uploadSpeed"`
|
||||
// Uploaded size(bytes)
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
// ExtractStatus indicates the current status of archive extraction
|
||||
ExtractStatus ExtractStatus `json:"extractStatus"`
|
||||
// ExtractProgress is the percentage of extraction completed (0-100)
|
||||
ExtractProgress int `json:"extractProgress"`
|
||||
// MultiPartBaseName is set for multi-part archives to group related parts
|
||||
MultiPartBaseName string `json:"multiPartBaseName,omitempty"`
|
||||
// MultiPartNumber is the part number for multi-part archives (1-indexed)
|
||||
MultiPartNumber int `json:"multiPartNumber,omitempty"`
|
||||
// MultiPartIsFirst indicates if this is the first part of a multi-part archive
|
||||
MultiPartIsFirst bool `json:"multiPartIsFirst,omitempty"`
|
||||
}
|
||||
|
||||
type Downloader struct {
|
||||
@@ -74,6 +104,10 @@ type Downloader struct {
|
||||
checkDuplicateLock *sync.Mutex
|
||||
closed atomic.Bool
|
||||
|
||||
// claimedExtractions tracks which multi-part archives have been claimed for extraction
|
||||
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
|
||||
claimedExtractions sync.Map
|
||||
|
||||
extensions []*Extension
|
||||
}
|
||||
|
||||
@@ -148,6 +182,7 @@ func (d *Downloader) Setup() error {
|
||||
tasks = append(tasks[:i], tasks[i+1:]...)
|
||||
continue
|
||||
}
|
||||
d.assignFetcherManager(task)
|
||||
initTask(task)
|
||||
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
|
||||
task.Status = base.DownloadStatusPause
|
||||
@@ -170,6 +205,9 @@ func (d *Downloader) Setup() error {
|
||||
}
|
||||
d.extensions = extensions
|
||||
|
||||
// Auto-cleanup non-existing tasks on startup
|
||||
d.cleanupNonExistingTasks()
|
||||
|
||||
// handle upload
|
||||
go func() {
|
||||
for _, task := range d.tasks {
|
||||
@@ -236,6 +274,51 @@ func (d *Downloader) Setup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupNonExistingTasks checks for tasks whose files are missing on disk
|
||||
// and removes them if the AutoCleanMissingFiles config is enabled.
|
||||
func (d *Downloader) cleanupNonExistingTasks() {
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If the feature is disabled, do nothing
|
||||
if !cfg.AutoDeleteMissingFileTasks {
|
||||
return
|
||||
}
|
||||
|
||||
var tasksToDelete []string
|
||||
|
||||
for _, task := range d.tasks {
|
||||
if task.Meta == nil || task.Meta.Res == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var targetPath string
|
||||
// Determine if it is a single file or a directory (multi-file torrent)
|
||||
if task.Meta.Res.Name != "" {
|
||||
targetPath = task.Meta.FolderPath()
|
||||
} else {
|
||||
targetPath = task.Meta.SingleFilepath()
|
||||
}
|
||||
|
||||
// Skip if path is empty
|
||||
if targetPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if file/folder exists
|
||||
if _, err := os.Stat(targetPath); os.IsNotExist(err) {
|
||||
d.Logger.Info().Msgf("Auto-cleanup: task %s file not found at %s, removing from list", task.ID, targetPath)
|
||||
tasksToDelete = append(tasksToDelete, task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasksToDelete) > 0 {
|
||||
d.Delete(&TaskFilter{IDs: tasksToDelete}, false)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Downloader) parseFm(url string) (fetcher.FetcherManager, error) {
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
for _, filter := range fm.Filters() {
|
||||
@@ -286,7 +369,7 @@ func (d *Downloader) saveTask(task *Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
|
||||
func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *ResolveResult, err error) {
|
||||
rrId, err := gonanoid.New()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -307,7 +390,11 @@ func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = fetcher.Resolve(req)
|
||||
initOpt, err := d.initOptions(opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = fetcher.Resolve(req, initOpt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -355,13 +442,21 @@ func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId
|
||||
return
|
||||
}
|
||||
fetcher.Meta().Req = req
|
||||
return d.doCreate(fetcher, opts)
|
||||
initOpt, err := d.initOptions(opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return d.doCreate(fetcher, initOpt)
|
||||
}
|
||||
|
||||
func (d *Downloader) CreateDirectBatch(reqs []*base.Request, opts *base.Options) (taskId []string, err error) {
|
||||
func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []string, err error) {
|
||||
taskIds := make([]string, 0)
|
||||
for _, req := range reqs {
|
||||
taskId, err := d.CreateDirect(req, opts.Clone())
|
||||
for _, ir := range req.Reqs {
|
||||
opts := ir.Opts
|
||||
if opts == nil {
|
||||
opts = req.Opts
|
||||
}
|
||||
taskId, err := d.CreateDirect(ir.Req, opts.Clone())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -370,7 +465,7 @@ func (d *Downloader) CreateDirectBatch(reqs []*base.Request, opts *base.Options)
|
||||
return taskIds, nil
|
||||
}
|
||||
|
||||
func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err error) {
|
||||
func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
||||
d.fetcherMapLock.RLock()
|
||||
fetcher, ok := d.fetcherCache[rrId]
|
||||
d.fetcherMapLock.RUnlock()
|
||||
@@ -382,7 +477,48 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err
|
||||
delete(d.fetcherCache, rrId)
|
||||
d.fetcherMapLock.Unlock()
|
||||
}()
|
||||
return d.doCreate(fetcher, opts)
|
||||
return d.doCreate(fetcher, nil)
|
||||
}
|
||||
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP protocol, it can modify Request info.
|
||||
// For BT protocol, it can modify SelectFiles.
|
||||
func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) error {
|
||||
task := d.GetTask(id)
|
||||
if task == nil {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
|
||||
// Restore fetcher if not loaded
|
||||
if task.fetcher == nil {
|
||||
err := func() error {
|
||||
task.statusLock.Lock()
|
||||
defer task.statusLock.Unlock()
|
||||
|
||||
return d.restoreFetcher(task)
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Call the fetcher's Patch method
|
||||
if err := task.fetcher.Patch(req, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update task meta from fetcher
|
||||
task.Meta = task.fetcher.Meta()
|
||||
|
||||
// Save task to storage
|
||||
if err := d.saveTask(task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Emit progress event to notify listeners
|
||||
d.emit(EventKeyProgress, task)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) Pause(filter *TaskFilter) (err error) {
|
||||
@@ -527,7 +663,7 @@ func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {
|
||||
|
||||
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
||||
if filter == nil || filter.IsEmpty() {
|
||||
return d.deleteAll()
|
||||
return d.deleteAll(force)
|
||||
}
|
||||
|
||||
deleteTasks := d.GetTasksByFilter(filter)
|
||||
@@ -572,17 +708,21 @@ func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) deleteAll() (err error) {
|
||||
func (d *Downloader) deleteAll(force bool) (err error) {
|
||||
var deleteTasksTemp []*Task
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for _, task := range d.tasks {
|
||||
deleteTasksTemp = append(deleteTasksTemp, task)
|
||||
}
|
||||
d.tasks = make([]*Task, 0)
|
||||
d.waitTasks = make([]*Task, 0)
|
||||
}()
|
||||
|
||||
for _, task := range d.tasks {
|
||||
if err = d.doDelete(task, true); err != nil {
|
||||
for _, task := range deleteTasksTemp {
|
||||
if err = d.doDelete(task, force); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -699,6 +839,9 @@ func (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) {
|
||||
}
|
||||
|
||||
func (d *Downloader) GetTask(id string) *Task {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for _, task := range d.tasks {
|
||||
if task.ID == id {
|
||||
return task
|
||||
@@ -708,12 +851,18 @@ func (d *Downloader) GetTask(id string) *Task {
|
||||
}
|
||||
|
||||
func (d *Downloader) GetTasks() []*Task {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
return d.tasks
|
||||
}
|
||||
|
||||
// GetTasksByFilter get tasks by filter, if filter is nil, return all tasks
|
||||
// return tasks and if match all tasks
|
||||
func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if filter == nil || filter.IsEmpty() {
|
||||
return d.tasks
|
||||
}
|
||||
@@ -803,8 +952,6 @@ func (d *Downloader) watch(task *Task) {
|
||||
if err != nil {
|
||||
d.Logger.Warn().Err(err).Msgf("task wait upload failed, task id: %s", task.ID)
|
||||
}
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
// Check if the task is deleted
|
||||
if d.GetTask(task.ID) != nil {
|
||||
@@ -847,10 +994,31 @@ func (d *Downloader) watch(task *Task) {
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
d.triggerOnDone(task)
|
||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
d.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
||||
downloadFilePath := task.Meta.SingleFilepath()
|
||||
if e.AutoTorrent && strings.HasSuffix(downloadFilePath, ".torrent") {
|
||||
|
||||
cfg, _ := d.GetConfig()
|
||||
|
||||
// Determine if auto-torrent is enabled (use global config if not explicitly set)
|
||||
autoTorrentEnabled := false
|
||||
if e.AutoTorrent != nil {
|
||||
autoTorrentEnabled = *e.AutoTorrent
|
||||
} else if cfg != nil && cfg.AutoTorrent != nil {
|
||||
autoTorrentEnabled = cfg.AutoTorrent.Enable
|
||||
}
|
||||
|
||||
if autoTorrentEnabled && strings.HasSuffix(downloadFilePath, ".torrent") {
|
||||
// Determine if should delete torrent file after creating BT task
|
||||
shouldDelete := false
|
||||
if e.DeleteTorrentAfterDownload != nil {
|
||||
shouldDelete = *e.DeleteTorrentAfterDownload
|
||||
} else if cfg != nil && cfg.AutoTorrent != nil {
|
||||
shouldDelete = cfg.AutoTorrent.DeleteAfterDownload
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err2 := d.CreateDirect(
|
||||
&base.Request{
|
||||
@@ -862,10 +1030,28 @@ func (d *Downloader) watch(task *Task) {
|
||||
})
|
||||
if err2 != nil {
|
||||
d.Logger.Error().Err(err2).Msgf("auto create torrent task failed, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
|
||||
if shouldDelete {
|
||||
d.Delete(&TaskFilter{IDs: []string{task.ID}}, true)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Determine if auto-extract is enabled (use global config if not explicitly set)
|
||||
autoExtractEnabled := false
|
||||
if e.AutoExtract != nil {
|
||||
autoExtractEnabled = *e.AutoExtract
|
||||
} else if cfg != nil && cfg.Archive != nil {
|
||||
autoExtractEnabled = cfg.Archive.AutoExtract
|
||||
}
|
||||
|
||||
// Auto-extract archive files using the extraction queue
|
||||
// This ensures only one extraction runs at a time to prevent resource exhaustion
|
||||
if autoExtractEnabled && isArchiveFile(downloadFilePath) {
|
||||
d.enqueueExtraction(task, downloadFilePath, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,6 +1063,7 @@ func (d *Downloader) doOnError(task *Task, err error) {
|
||||
d.emit(EventKeyError, task, err)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
d.triggerWebhooks(WebhookEventDownloadError, task, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,23 +1074,11 @@ func (d *Downloader) restoreTask(task *Task) error {
|
||||
}
|
||||
}
|
||||
go d.watch(task)
|
||||
task.fetcher.Create(task.Meta.Opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) restoreFetcher(task *Task) error {
|
||||
var fm fetcher.FetcherManager
|
||||
for _, f := range d.cfg.FetchManagers {
|
||||
if f.Name() == task.Protocol {
|
||||
fm = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if fm == nil {
|
||||
return ErrUnSupportedProtocol
|
||||
}
|
||||
task.fetcherManager = fm
|
||||
v, f := fm.Restore()
|
||||
v, f := task.fetcherManager.Restore()
|
||||
if v != nil {
|
||||
err := d.storage.Pop(bucketSave, task.ID, v)
|
||||
if err != nil {
|
||||
@@ -921,39 +1096,15 @@ func (d *Downloader) restoreFetcher(task *Task) error {
|
||||
if task.fetcher.Meta().Res == nil {
|
||||
task.fetcher.Meta().Res = task.Meta.Res
|
||||
}
|
||||
if task.fetcher.Meta().Opts == nil {
|
||||
task.fetcher.Meta().Opts = task.Meta.Opts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) {
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
if opts.SelectFiles == nil {
|
||||
opts.SelectFiles = make([]int, 0)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
meta.Opts = opts
|
||||
if opts.Path == "" {
|
||||
storeConfig, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
opts.Path = storeConfig.DownloadDir
|
||||
}
|
||||
|
||||
// if enable white download directory, check if the download directory is in the white list
|
||||
if len(d.cfg.DownloadDirWhiteList) > 0 {
|
||||
inWhiteList := false
|
||||
for _, dir := range d.cfg.DownloadDirWhiteList {
|
||||
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
|
||||
inWhiteList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inWhiteList {
|
||||
return "", errors.New("download directory is not in white list")
|
||||
}
|
||||
if f.Meta().Opts == nil {
|
||||
f.Meta().Opts = opts
|
||||
}
|
||||
|
||||
fm, err := d.parseFm(f.Meta().Req.URL)
|
||||
@@ -969,9 +1120,6 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
||||
task.Progress = &Progress{}
|
||||
_, task.Uploading = f.(fetcher.Uploader)
|
||||
initTask(task)
|
||||
if err = f.Create(opts); err != nil {
|
||||
return
|
||||
}
|
||||
if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -997,6 +1145,39 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) {
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
if opts.SelectFiles == nil {
|
||||
opts.SelectFiles = make([]int, 0)
|
||||
}
|
||||
if opts.Path == "" {
|
||||
storeConfig, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Path = storeConfig.DownloadDir
|
||||
}
|
||||
// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
|
||||
opts.Path = util.ReplacePathPlaceholders(opts.Path)
|
||||
|
||||
// if enable white download directory, check if the download directory is in the white list
|
||||
if len(d.cfg.WhiteDownloadDirs) > 0 {
|
||||
inWhiteList := false
|
||||
for _, dir := range d.cfg.WhiteDownloadDirs {
|
||||
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
|
||||
inWhiteList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inWhiteList {
|
||||
return nil, errors.New("download directory is not in white list")
|
||||
}
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) {
|
||||
task.statusLock.Lock()
|
||||
defer task.statusLock.Unlock()
|
||||
@@ -1036,7 +1217,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
|
||||
d.triggerOnStart(task)
|
||||
if task.Meta.Res == nil {
|
||||
err := task.fetcher.Resolve(task.Meta.Req)
|
||||
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1047,10 +1228,10 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
if task.fetcherManager.AutoRename() {
|
||||
d.checkDuplicateLock.Lock()
|
||||
defer d.checkDuplicateLock.Unlock()
|
||||
task.Meta.Opts.Name = util.ReplaceInvalidFilename(task.Meta.Opts.Name)
|
||||
task.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name)
|
||||
// check if the download file is duplicated and rename it automatically.
|
||||
if task.Meta.Res.Name != "" {
|
||||
task.Meta.Res.Name = util.ReplaceInvalidFilename(task.Meta.Res.Name)
|
||||
task.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name)
|
||||
fullDirPath := task.Meta.FolderPath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullDirPath)
|
||||
if err != nil {
|
||||
@@ -1058,7 +1239,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
}
|
||||
task.Meta.Opts.Name = newName
|
||||
} else {
|
||||
task.Meta.Res.Files[0].Name = util.ReplaceInvalidFilename(task.Meta.Res.Files[0].Name)
|
||||
task.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name)
|
||||
fullFilePath := task.Meta.SingleFilepath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullFilePath)
|
||||
if err != nil {
|
||||
@@ -1145,6 +1326,15 @@ func logPanic(logDir string) {
|
||||
debug.SetCrashOutput(f, debug.CrashOptions{})
|
||||
}
|
||||
|
||||
func (d *Downloader) assignFetcherManager(task *Task) error {
|
||||
fm, err := d.parseFm(task.Meta.Req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task.fetcherManager = fm
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {
|
||||
fm, err := d.parseFm(url)
|
||||
if err != nil {
|
||||
@@ -1155,6 +1345,384 @@ func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {
|
||||
return fetcher, nil
|
||||
}
|
||||
|
||||
// enqueueExtraction adds an extraction job to the global extraction queue
|
||||
// This ensures only one extraction (or one multi-part archive extraction) runs at a time
|
||||
// to prevent resource exhaustion
|
||||
func (d *Downloader) enqueueExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
|
||||
partInfo := getArchivePartInfo(downloadFilePath)
|
||||
|
||||
if partInfo.IsMultiPart {
|
||||
// For multi-part archives, handle specially
|
||||
d.enqueueMultiPartExtraction(task, downloadFilePath, partInfo, opts)
|
||||
} else {
|
||||
// For single archives, queue immediately
|
||||
d.enqueueSingleExtraction(task, downloadFilePath, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueSingleExtraction queues extraction for a single (non-multi-part) archive
|
||||
func (d *Downloader) enqueueSingleExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
|
||||
jobID := "single:" + task.ID
|
||||
|
||||
// Set extraction status to queued
|
||||
task.Progress.ExtractStatus = ExtractStatusQueued
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.Logger.Info().Msgf("extraction queued, task id: %s, job id: %s", task.ID, jobID)
|
||||
|
||||
// Create and enqueue the extraction job
|
||||
job := NewExtractionJob(jobID, func() {
|
||||
d.performExtraction(task, downloadFilePath, task.Meta.Opts.Path, opts)
|
||||
})
|
||||
|
||||
go func() {
|
||||
GetExtractionQueue().Enqueue(job)
|
||||
}()
|
||||
}
|
||||
|
||||
// enqueueMultiPartExtraction handles queueing for multi-part archives
|
||||
// It ensures only ONE extraction job is queued when ALL parts are ready
|
||||
func (d *Downloader) enqueueMultiPartExtraction(task *Task, downloadFilePath string, partInfo ArchivePartInfo, opts *http.OptsExtra) {
|
||||
// Set multi-part info on the task
|
||||
task.Progress.MultiPartBaseName = partInfo.BaseName
|
||||
task.Progress.MultiPartNumber = partInfo.PartNumber
|
||||
task.Progress.MultiPartIsFirst = isFirstPart(downloadFilePath)
|
||||
|
||||
// Check if all parts are downloaded
|
||||
destDir := task.Meta.Opts.Path
|
||||
allPartsReady, missingParts := d.checkMultiPartArchiveReady(downloadFilePath, destDir, partInfo)
|
||||
|
||||
if !allPartsReady {
|
||||
// Not all parts are ready yet - just set status to waiting, don't queue anything
|
||||
task.Progress.ExtractStatus = ExtractStatusWaitingParts
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.Logger.Info().Msgf("multi-part archive waiting for other parts, task id: %s, missing: %v", task.ID, missingParts)
|
||||
return
|
||||
}
|
||||
|
||||
// All parts are ready! Atomically check if extraction has already been started/queued
|
||||
// and if not, mark this task as the one that will handle it
|
||||
// Use GetMultiPartArchiveBaseName to get the full path for comparison
|
||||
fullBaseName := GetMultiPartArchiveBaseName(downloadFilePath)
|
||||
shouldQueue := d.tryClaimMultiPartExtraction(task, fullBaseName)
|
||||
|
||||
if !shouldQueue {
|
||||
// Another part already started/queued extraction, mark this task as done
|
||||
task.Progress.ExtractStatus = ExtractStatusDone
|
||||
task.Progress.ExtractProgress = 100
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.Logger.Info().Msgf("multi-part archive extraction already handled by another part, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// This task claimed the extraction - status already set to queued in tryClaimMultiPartExtraction
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
|
||||
jobID := "multipart:" + fullBaseName
|
||||
d.Logger.Info().Msgf("multi-part extraction queued, task id: %s, job id: %s", task.ID, jobID)
|
||||
|
||||
// Create and enqueue the extraction job
|
||||
job := NewExtractionJob(jobID, func() {
|
||||
d.performMultiPartExtraction(task, partInfo.FirstPartPath, destDir, opts)
|
||||
})
|
||||
|
||||
go func() {
|
||||
GetExtractionQueue().Enqueue(job)
|
||||
}()
|
||||
}
|
||||
|
||||
// checkMultiPartArchiveReady checks if all parts of a multi-part archive are downloaded
|
||||
// by examining task status rather than file existence
|
||||
func (d *Downloader) checkMultiPartArchiveReady(filePath string, destDir string, partInfo ArchivePartInfo) (bool, []string) {
|
||||
// Use task-based checking - find all tasks with the same MultiPartBaseName
|
||||
// and verify they are all in Done status
|
||||
baseName := GetMultiPartArchiveBaseName(filePath)
|
||||
if baseName == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return d.checkAllMultiPartTasksDone(baseName)
|
||||
}
|
||||
|
||||
// checkAllMultiPartTasksDone checks if all tasks belonging to a multi-part archive are done
|
||||
func (d *Downloader) checkAllMultiPartTasksDone(baseName string) (bool, []string) {
|
||||
var notDoneParts []string
|
||||
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
// Find all tasks that belong to this multi-part archive
|
||||
var relatedTasks []*Task
|
||||
for _, task := range d.tasks {
|
||||
taskBaseName := ""
|
||||
if task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 {
|
||||
taskBaseName = GetMultiPartArchiveBaseName(task.Meta.SingleFilepath())
|
||||
}
|
||||
if taskBaseName == baseName {
|
||||
relatedTasks = append(relatedTasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
// If we found no related tasks, we can't determine readiness
|
||||
if len(relatedTasks) == 0 {
|
||||
return false, []string{"no related tasks found for " + baseName}
|
||||
}
|
||||
|
||||
// Check if all related tasks are done
|
||||
for _, task := range relatedTasks {
|
||||
if task.Status != base.DownloadStatusDone {
|
||||
notDoneParts = append(notDoneParts, task.Meta.SingleFilepath())
|
||||
}
|
||||
}
|
||||
|
||||
return len(notDoneParts) == 0, notDoneParts
|
||||
}
|
||||
|
||||
// tryClaimMultiPartExtraction atomically checks if extraction can be claimed for a multi-part archive
|
||||
// and if so, marks the task as queued. Returns true if this task should proceed with queueing.
|
||||
// This uses sync.Map.LoadOrStore for atomic claim to prevent race conditions.
|
||||
func (d *Downloader) tryClaimMultiPartExtraction(task *Task, baseName string) bool {
|
||||
// Use LoadOrStore for atomic claim - if another goroutine already stored a value, we get that value back
|
||||
_, alreadyClaimed := d.claimedExtractions.LoadOrStore(baseName, task.ID)
|
||||
if alreadyClaimed {
|
||||
return false // Another task already claimed it
|
||||
}
|
||||
|
||||
// This task successfully claimed it
|
||||
task.Progress.ExtractStatus = ExtractStatusQueued
|
||||
return true
|
||||
}
|
||||
|
||||
// releaseMultiPartExtractionClaim releases the extraction claim for a multi-part archive
|
||||
// This is primarily used for testing purposes
|
||||
func (d *Downloader) releaseMultiPartExtractionClaim(baseName string) {
|
||||
d.claimedExtractions.Delete(baseName)
|
||||
}
|
||||
|
||||
// performExtraction performs extraction for a regular (non-multi-part) archive
|
||||
func (d *Downloader) performExtraction(task *Task, archivePath string, destDir string, opts *http.OptsExtra) {
|
||||
// Set extraction status to extracting
|
||||
task.Progress.ExtractStatus = ExtractStatusExtracting
|
||||
task.Progress.ExtractProgress = 0
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
|
||||
// Extract the archive
|
||||
extractErr := extractArchive(archivePath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
|
||||
task.Progress.ExtractProgress = progress
|
||||
d.emit(EventKeyProgress, task)
|
||||
})
|
||||
|
||||
d.handleExtractionResult(task, extractErr, []string{archivePath}, opts.DeleteAfterExtract)
|
||||
}
|
||||
|
||||
// performMultiPartExtraction performs extraction for a multi-part archive
|
||||
func (d *Downloader) performMultiPartExtraction(task *Task, firstPartPath string, destDir string, opts *http.OptsExtra) {
|
||||
// Get the baseName for releasing the claim later
|
||||
fullBaseName := GetMultiPartArchiveBaseName(firstPartPath)
|
||||
|
||||
// Set extraction status to extracting
|
||||
task.Progress.ExtractStatus = ExtractStatusExtracting
|
||||
task.Progress.ExtractProgress = 0
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
|
||||
d.Logger.Info().Msgf("starting multi-part archive extraction, first part: %s, task id: %s", firstPartPath, task.ID)
|
||||
|
||||
// Extract the multi-part archive
|
||||
extractErr := extractMultiPartArchive(firstPartPath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
|
||||
task.Progress.ExtractProgress = progress
|
||||
d.emit(EventKeyProgress, task)
|
||||
})
|
||||
|
||||
// Collect all part files for potential deletion
|
||||
partFiles := d.collectMultiPartFiles(firstPartPath)
|
||||
|
||||
d.handleExtractionResult(task, extractErr, partFiles, opts.DeleteAfterExtract)
|
||||
|
||||
// Update status for all related multi-part tasks
|
||||
d.updateMultiPartTasksStatus(task, extractErr)
|
||||
|
||||
// Release the claim so future downloads of the same archive can be extracted
|
||||
d.releaseMultiPartExtractionClaim(fullBaseName)
|
||||
}
|
||||
|
||||
// collectMultiPartFiles collects all files belonging to a multi-part archive
|
||||
func (d *Downloader) collectMultiPartFiles(firstPartPath string) []string {
|
||||
var files []string
|
||||
partInfo := getArchivePartInfo(firstPartPath)
|
||||
dir := filepath.Dir(firstPartPath)
|
||||
|
||||
switch {
|
||||
case strings.Contains(partInfo.Pattern, ".7z)"):
|
||||
// 7z: .7z.001, .7z.002, etc.
|
||||
files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d")
|
||||
case strings.Contains(partInfo.Pattern, ".part"):
|
||||
// RAR new style
|
||||
files = d.collectRarNewStyleFiles(dir, partInfo.BaseName)
|
||||
case partInfo.Pattern == "rar-old-style" || strings.Contains(partInfo.Pattern, ".r("):
|
||||
// RAR old style
|
||||
files = d.collectRarOldStyleFiles(dir, partInfo.BaseName)
|
||||
case strings.Contains(partInfo.Pattern, ".zip)"):
|
||||
// ZIP multi-part
|
||||
files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d")
|
||||
case strings.Contains(partInfo.Pattern, ".z("):
|
||||
// ZIP split
|
||||
files = d.collectZipSplitFiles(dir, partInfo.BaseName)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// collectSequentialFiles collects sequential numbered files
|
||||
func (d *Downloader) collectSequentialFiles(dir, baseName, format string) []string {
|
||||
var files []string
|
||||
suffix := filepath.Ext(baseName)
|
||||
nameWithoutExt := strings.TrimSuffix(baseName, suffix)
|
||||
partNum := 1
|
||||
|
||||
for {
|
||||
partPath := filepath.Join(dir, nameWithoutExt+suffix+fmt.Sprintf(format, partNum))
|
||||
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
files = append(files, partPath)
|
||||
partNum++
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// collectRarNewStyleFiles collects RAR new style part files
|
||||
func (d *Downloader) collectRarNewStyleFiles(dir, baseName string) []string {
|
||||
var files []string
|
||||
partNum := 1
|
||||
|
||||
for {
|
||||
singleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%d.rar", partNum))
|
||||
doubleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%02d.rar", partNum))
|
||||
|
||||
if _, err := os.Stat(singleDigitPath); err == nil {
|
||||
files = append(files, singleDigitPath)
|
||||
} else if _, err := os.Stat(doubleDigitPath); err == nil {
|
||||
files = append(files, doubleDigitPath)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
partNum++
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// collectRarOldStyleFiles collects RAR old style part files
|
||||
func (d *Downloader) collectRarOldStyleFiles(dir, baseName string) []string {
|
||||
var files []string
|
||||
|
||||
// .rar file
|
||||
rarPath := filepath.Join(dir, baseName+".rar")
|
||||
if _, err := os.Stat(rarPath); err == nil {
|
||||
files = append(files, rarPath)
|
||||
}
|
||||
|
||||
// .r00, .r01, etc.
|
||||
partNum := 0
|
||||
for {
|
||||
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".r%02d", partNum))
|
||||
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
files = append(files, partPath)
|
||||
partNum++
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// collectZipSplitFiles collects ZIP split files
|
||||
func (d *Downloader) collectZipSplitFiles(dir, baseName string) []string {
|
||||
var files []string
|
||||
|
||||
// .z01, .z02, etc.
|
||||
partNum := 1
|
||||
for {
|
||||
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".z%02d", partNum))
|
||||
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
files = append(files, partPath)
|
||||
partNum++
|
||||
}
|
||||
|
||||
// .zip file
|
||||
zipPath := filepath.Join(dir, baseName+".zip")
|
||||
if _, err := os.Stat(zipPath); err == nil {
|
||||
files = append(files, zipPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// handleExtractionResult handles the result of an extraction operation
|
||||
func (d *Downloader) handleExtractionResult(task *Task, extractErr error, archiveFiles []string, deleteAfterExtract bool) {
|
||||
if extractErr != nil {
|
||||
d.Logger.Error().Err(extractErr).Msgf("auto extract archive failed, task id: %s", task.ID)
|
||||
task.Progress.ExtractStatus = ExtractStatusError
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
} else {
|
||||
d.Logger.Info().Msgf("auto extract archive completed, task id: %s", task.ID)
|
||||
task.Progress.ExtractStatus = ExtractStatusDone
|
||||
task.Progress.ExtractProgress = 100
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
|
||||
// Delete archive files after successful extraction if enabled
|
||||
if deleteAfterExtract {
|
||||
for _, archiveFile := range archiveFiles {
|
||||
deleteErr := os.Remove(archiveFile)
|
||||
if deleteErr != nil {
|
||||
d.Logger.Error().Err(deleteErr).Msgf("delete archive after extraction failed: %s", archiveFile)
|
||||
} else {
|
||||
d.Logger.Info().Msgf("archive deleted after extraction: %s", archiveFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateMultiPartTasksStatus updates the extraction status for all tasks that belong to the same multi-part archive
|
||||
func (d *Downloader) updateMultiPartTasksStatus(sourceTask *Task, extractErr error) {
|
||||
if sourceTask.Progress.MultiPartBaseName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
status := ExtractStatusDone
|
||||
progress := 100
|
||||
if extractErr != nil {
|
||||
status = ExtractStatusError
|
||||
progress = 0
|
||||
}
|
||||
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for _, task := range d.tasks {
|
||||
if task.ID == sourceTask.ID {
|
||||
continue
|
||||
}
|
||||
if task.Progress.MultiPartBaseName == sourceTask.Progress.MultiPartBaseName {
|
||||
task.Progress.ExtractStatus = status
|
||||
task.Progress.ExtractProgress = progress
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initTask(task *Task) {
|
||||
task.timer = util.NewTimer(task.Progress.Used)
|
||||
|
||||
@@ -1182,13 +1750,6 @@ func (b *boot) Extra(extra interface{}) *boot {
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *boot) Resolve() (*ResolveResult, error) {
|
||||
return defaultDownloader.Resolve(&base.Request{
|
||||
URL: b.url,
|
||||
Extra: b.extra,
|
||||
})
|
||||
}
|
||||
|
||||
func (b *boot) Listener(listener Listener) *boot {
|
||||
b.listener = listener
|
||||
return b
|
||||
|
||||
+2165
-66
File diff suppressed because it is too large.
Load diff
@@ -205,6 +205,31 @@ function testTimeout(){
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
async function testFingerprint(fingerprint,ua){
|
||||
__gopeed_setFingerprint(fingerprint);
|
||||
const resp = await fetch(host+'/ua');
|
||||
const data = await resp.json();
|
||||
if(!data.user_agent.includes(ua)){
|
||||
throw new Error('fingerprint test failed, user agent: ' + data.user_agent);
|
||||
}
|
||||
}
|
||||
|
||||
async function testFingerprintDefault(){
|
||||
await testFingerprint('none', 'Go')
|
||||
}
|
||||
|
||||
async function testFingerprintChrome(){
|
||||
await testFingerprint('chrome', 'Chrome')
|
||||
}
|
||||
|
||||
async function testFingerprintFirefox(){
|
||||
await testFingerprint('firefox', 'Firefox')
|
||||
}
|
||||
|
||||
async function testFingerprintSafari(){
|
||||
await testFingerprint('safari', 'Safari')
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -288,6 +313,19 @@ function testTimeout(){
|
||||
if err == nil || err.Error() != "timeout" {
|
||||
t.Fatalf("timeout test failed, want %s, got %s", "timeout", err)
|
||||
}
|
||||
|
||||
_, err = callTestFun(engine, "testFingerprintChrome")
|
||||
if err != nil {
|
||||
t.Fatal("testFingerprintChrome test failed", err)
|
||||
}
|
||||
_, err = callTestFun(engine, "testFingerprintFirefox")
|
||||
if err != nil {
|
||||
t.Fatal("testFingerprintFirefox test failed", err)
|
||||
}
|
||||
_, err = callTestFun(engine, "testFingerprintSafari")
|
||||
if err != nil {
|
||||
t.Fatal("testFingerprintSafari test failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWithProxy(t *testing.T) {
|
||||
@@ -470,6 +508,15 @@ func startServer() net.Listener {
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
data := map[string]any{
|
||||
"user_agent": r.UserAgent(),
|
||||
}
|
||||
buf, _ := json.Marshal(data)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(buf)
|
||||
})
|
||||
server.Handler = mux
|
||||
go server.Serve(listener)
|
||||
return listener
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -129,7 +130,8 @@ type XMLHttpRequest struct {
|
||||
requestHeaders http.Header
|
||||
responseHeaders http.Header
|
||||
aborted bool
|
||||
proxyHandler func(r *http.Request) (*url.URL, error)
|
||||
client *req.Client
|
||||
fingerprint string
|
||||
|
||||
WithCredentials bool `json:"withCredentials"`
|
||||
Upload *XMLHttpRequestUpload `json:"upload"`
|
||||
@@ -163,25 +165,39 @@ func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
setFingerprint(xhr.client, xhr.fingerprint)
|
||||
|
||||
d := xhr.parseData(data)
|
||||
var (
|
||||
contentType string
|
||||
contentLength int64
|
||||
isStringBody bool
|
||||
)
|
||||
if d == nil || xhr.method == "GET" || xhr.method == "HEAD" {
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, nil)
|
||||
} else {
|
||||
|
||||
// Create request using req library
|
||||
reqBuilder := xhr.client.R()
|
||||
|
||||
// Set headers first
|
||||
if xhr.requestHeaders != nil {
|
||||
for key, values := range xhr.requestHeaders {
|
||||
if len(values) > 0 {
|
||||
// Merge multiple values with comma separator (HTTP standard)
|
||||
mergedValue := strings.Join(values, ", ")
|
||||
reqBuilder.SetHeader(key, mergedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle request body
|
||||
if d != nil && xhr.method != "GET" && xhr.method != "HEAD" {
|
||||
switch v := d.(type) {
|
||||
case string:
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, bytes.NewBufferString(v))
|
||||
reqBuilder.SetBody(v)
|
||||
contentType = "text/plain;charset=UTF-8"
|
||||
contentLength = int64(len(v))
|
||||
isStringBody = true
|
||||
case *file.File:
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, v.Reader)
|
||||
reqBuilder.SetBody(v.Reader)
|
||||
contentType = "application/octet-stream"
|
||||
contentLength = v.Size
|
||||
case *formdata.FormData:
|
||||
@@ -203,43 +219,38 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||
defer mw.Close()
|
||||
mw.Send()
|
||||
}()
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, pr)
|
||||
reqBuilder.SetBody(pr)
|
||||
contentType = mw.FormDataContentType()
|
||||
contentLength = mw.Size()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
xhr.callOnerror()
|
||||
return
|
||||
}
|
||||
req.Header = xhr.requestHeaders
|
||||
|
||||
// Only string body can specify Content-Type header by user
|
||||
if contentType != "" && (!isStringBody || req.Header.Get("Content-Type") == "") {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") {
|
||||
reqBuilder.SetHeader("Content-Type", contentType)
|
||||
}
|
||||
if contentLength > 0 {
|
||||
req.ContentLength = contentLength
|
||||
|
||||
// Set timeout
|
||||
if xhr.Timeout > 0 {
|
||||
xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: xhr.proxyHandler,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: time.Duration(xhr.Timeout) * time.Millisecond,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if xhr.Redirect == redirectManual {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if xhr.Redirect == redirectError {
|
||||
return errors.New("redirect failed")
|
||||
}
|
||||
if len(via) > 20 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
|
||||
// Configure redirect behavior
|
||||
xhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
|
||||
if xhr.Redirect == redirectManual {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if xhr.Redirect == redirectError {
|
||||
return errors.New("redirect failed")
|
||||
}
|
||||
if len(via) > 20 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Execute request
|
||||
resp, err := reqBuilder.Send(xhr.method, xhr.url)
|
||||
if err != nil {
|
||||
// handle timeout error
|
||||
var ne net.Error
|
||||
@@ -254,35 +265,37 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||
xhr.callOnerror()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
xhr.Upload.callOnprogress(contentLength, contentLength)
|
||||
if !xhr.aborted {
|
||||
xhr.Upload.callOnload()
|
||||
}
|
||||
|
||||
responseUrl := resp.Request.URL
|
||||
responseUrl.Fragment = ""
|
||||
xhr.ResponseUrl = responseUrl.String()
|
||||
// Set response URL (final URL after redirects)
|
||||
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
|
||||
responseUrl := resp.Response.Request.URL
|
||||
responseUrl.Fragment = ""
|
||||
xhr.ResponseUrl = responseUrl.String()
|
||||
} else {
|
||||
xhr.ResponseUrl = xhr.url
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
xhr.responseHeaders = resp.Header
|
||||
xhr.Status = resp.StatusCode
|
||||
xhr.StatusText = resp.Status
|
||||
xhr.doReadystatechange(2)
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xhr.callOnerror()
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes := resp.Bytes()
|
||||
xhr.doReadystatechange(3)
|
||||
xhr.Response = string(buf)
|
||||
xhr.Response = string(bodyBytes)
|
||||
xhr.ResponseText = xhr.Response
|
||||
xhr.doReadystatechange(4)
|
||||
respBodyLen := int64(len(buf))
|
||||
respBodyLen := int64(len(bodyBytes))
|
||||
xhr.callOnprogress(respBodyLen, respBodyLen)
|
||||
if !xhr.aborted {
|
||||
xhr.callOnload()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) Abort() {
|
||||
@@ -293,6 +306,9 @@ func (xhr *XMLHttpRequest) Abort() {
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
|
||||
if xhr.responseHeaders == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(xhr.responseHeaders.Values(key), ", ")
|
||||
}
|
||||
|
||||
@@ -359,8 +375,15 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
|
||||
return instanceValue
|
||||
})
|
||||
xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
|
||||
// Create req client with proxy support
|
||||
client := req.C()
|
||||
if proxyHandler != nil {
|
||||
client.SetProxy(proxyHandler)
|
||||
}
|
||||
|
||||
instance := &XMLHttpRequest{
|
||||
proxyHandler: proxyHandler,
|
||||
client: client,
|
||||
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
|
||||
Upload: &XMLHttpRequestUpload{
|
||||
EventProp: &EventProp{
|
||||
eventListeners: make(map[string]func(event *ProgressEvent)),
|
||||
@@ -374,6 +397,9 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
return instanceValue
|
||||
})
|
||||
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
|
||||
runtime.Set(FingerprintMagicKey, fingerprint)
|
||||
})
|
||||
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package xhr
|
||||
|
||||
import "github.com/imroc/req/v3"
|
||||
|
||||
type Fingerprint string
|
||||
|
||||
const (
|
||||
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
|
||||
|
||||
fingerprintChrome = "chrome"
|
||||
fingerprintFirefox = "firefox"
|
||||
fingerprintSafari = "safari"
|
||||
)
|
||||
|
||||
func setFingerprint(client *req.Client, fingerprint string) {
|
||||
switch fingerprint {
|
||||
case fingerprintChrome:
|
||||
client.ImpersonateChrome()
|
||||
case fingerprintFirefox:
|
||||
client.ImpersonateFirefox()
|
||||
case fingerprintSafari:
|
||||
client.ImpersonateSafari()
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -217,7 +217,7 @@
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
|
||||
"@webassemblyjs/helper-wasm-section": "1.11.1",
|
||||
"@webassemblyjs/wasm-gen": "1.11.1",
|
||||
"@webassemblyjs/wasm-opt": "1.11.1",
|
||||
"@webassemblyjs/wasm-opts": "1.11.1",
|
||||
"@webassemblyjs/wasm-parser": "1.11.1",
|
||||
"@webassemblyjs/wast-printer": "1.11.1"
|
||||
}
|
||||
@@ -235,9 +235,9 @@
|
||||
"@webassemblyjs/utf8": "1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-opt": {
|
||||
"node_modules/@webassemblyjs/wasm-opts": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz",
|
||||
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
@@ -2114,7 +2114,7 @@
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
|
||||
"@webassemblyjs/helper-wasm-section": "1.11.1",
|
||||
"@webassemblyjs/wasm-gen": "1.11.1",
|
||||
"@webassemblyjs/wasm-opt": "1.11.1",
|
||||
"@webassemblyjs/wasm-opts": "1.11.1",
|
||||
"@webassemblyjs/wasm-parser": "1.11.1",
|
||||
"@webassemblyjs/wast-printer": "1.11.1"
|
||||
}
|
||||
@@ -2132,9 +2132,9 @@
|
||||
"@webassemblyjs/utf8": "1.11.1"
|
||||
}
|
||||
},
|
||||
"@webassemblyjs/wasm-opt": {
|
||||
"@webassemblyjs/wasm-opts": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz",
|
||||
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
|
||||
@@ -22,3 +22,16 @@ func AssertError[T error](err error) (t T, r bool) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func SafeGet[T any](vm *goja.Runtime, name string) T {
|
||||
v := vm.Get(name)
|
||||
if v == nil {
|
||||
var init T
|
||||
return init
|
||||
}
|
||||
if e, ok := v.Export().(T); ok {
|
||||
return e
|
||||
}
|
||||
var init T
|
||||
return init
|
||||
}
|
||||
+16
-12
@@ -3,6 +3,14 @@ package download
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
@@ -12,13 +20,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -200,10 +201,14 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
|
||||
subPath = pathArr[1]
|
||||
}
|
||||
|
||||
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, projectPath)
|
||||
if err := util.RmAndMkDirAll(tempExtDir); err != nil {
|
||||
// Use unique suffix to avoid concurrent conflicts when multiple git operations
|
||||
// target the same extension (e.g., install and update check happening simultaneously)
|
||||
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, fmt.Sprintf("%s_%d", projectPath, time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(tempExtDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tempExtDir)
|
||||
|
||||
proxyOptions := transport.ProxyOptions{}
|
||||
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
|
||||
if proxyUrl != nil {
|
||||
@@ -220,7 +225,6 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tempExtDir)
|
||||
|
||||
return handler(filepath.Join(tempExtDir, subPath), false)
|
||||
}
|
||||
@@ -260,9 +264,9 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
|
||||
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
|
||||
return
|
||||
}
|
||||
ctx.Res.Name = util.ReplaceInvalidFilename(ctx.Res.Name)
|
||||
ctx.Res.Name = util.SafeFilename(ctx.Res.Name)
|
||||
for _, file := range ctx.Res.Files {
|
||||
file.Name = util.ReplaceInvalidFilename(file.Name)
|
||||
file.Name = util.SafeFilename(file.Name)
|
||||
}
|
||||
ctx.Res.CalcSize(nil)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ package download
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
"github.com/dop251/goja"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
||||
@@ -18,7 +19,7 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -35,7 +36,7 @@ func TestDownloader_InstallExtensionByFolderDevMode(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -52,7 +53,7 @@ func TestDownloader_InstallExtensionByGit(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -69,7 +70,7 @@ func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -86,7 +87,7 @@ func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -156,7 +157,7 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://test.com",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -185,7 +186,7 @@ func TestDownloader_Extension_OnStart(t *testing.T) {
|
||||
select {
|
||||
case err = <-errCh:
|
||||
break
|
||||
case <-time.After(time.Second * 10):
|
||||
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
||||
err = errors.New("timeout")
|
||||
}
|
||||
if err != nil {
|
||||
@@ -238,7 +239,7 @@ func TestDownloader_Extension_OnError(t *testing.T) {
|
||||
select {
|
||||
case err = <-errCh:
|
||||
break
|
||||
case <-time.After(time.Second * 10):
|
||||
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
||||
err = errors.New("timeout")
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@ func TestDownloader_Extension_OnDone(t *testing.T) {
|
||||
select {
|
||||
case err = <-errCh:
|
||||
break
|
||||
case <-time.After(time.Second * 10):
|
||||
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
||||
err = errors.New("timeout")
|
||||
}
|
||||
// wait for script execution
|
||||
@@ -300,7 +301,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -315,7 +316,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -330,7 +331,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
||||
}
|
||||
_, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("except error, but got nil")
|
||||
}
|
||||
@@ -352,7 +353,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -373,7 +374,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
|
||||
})
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -390,7 +391,7 @@ func TestDownloader_ExtensionStorage(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
|
||||
var supportedArchiveExtensions = []string{
|
||||
// Archive formats
|
||||
".zip",
|
||||
".tar",
|
||||
".tar.gz", ".tgz",
|
||||
".tar.bz2", ".tbz2",
|
||||
".tar.xz", ".txz",
|
||||
".tar.lz4", ".tlz4",
|
||||
".tar.sz", ".tsz",
|
||||
".tar.zst", ".tzst",
|
||||
".rar",
|
||||
".7z",
|
||||
// Compression formats
|
||||
".gz",
|
||||
".bz2",
|
||||
".xz",
|
||||
".lz4",
|
||||
".sz",
|
||||
".zst",
|
||||
".br",
|
||||
".lz",
|
||||
}
|
||||
|
||||
// multiPartArchivePatterns contains regex patterns for multi-part archive detection
|
||||
// Each pattern should have a capture group for the base name and part number
|
||||
var multiPartArchivePatterns = []*regexp.Regexp{
|
||||
// 7z multi-part: file.7z.001, file.7z.002, etc.
|
||||
regexp.MustCompile(`(?i)^(.+\.7z)\.(\d{3})$`),
|
||||
// RAR multi-part (new style): file.part01.rar, file.part02.rar, etc.
|
||||
regexp.MustCompile(`(?i)^(.+)\.part(\d+)\.rar$`),
|
||||
// RAR multi-part (old style): file.rar, file.r00, file.r01, etc. (first file is .rar)
|
||||
regexp.MustCompile(`(?i)^(.+)\.r(\d{2})$`),
|
||||
// ZIP multi-part: file.zip.001, file.zip.002, etc.
|
||||
regexp.MustCompile(`(?i)^(.+\.zip)\.(\d{3})$`),
|
||||
// ZIP split: file.z01, file.z02, ... file.zip (last file is .zip)
|
||||
regexp.MustCompile(`(?i)^(.+)\.z(\d{2})$`),
|
||||
}
|
||||
|
||||
// ArchivePartInfo contains information about a multi-part archive
|
||||
type ArchivePartInfo struct {
|
||||
// IsMultiPart indicates if this file is part of a multi-part archive
|
||||
IsMultiPart bool
|
||||
// BaseName is the common base name for all parts (without part number extension)
|
||||
BaseName string
|
||||
// PartNumber is the part number (1-indexed)
|
||||
PartNumber int
|
||||
// FirstPartPath is the path to the first part of the archive
|
||||
FirstPartPath string
|
||||
// Pattern indicates which pattern matched (for determining extraction method)
|
||||
Pattern string
|
||||
}
|
||||
|
||||
// ExtractProgressCallback is called to report extraction progress
|
||||
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
|
||||
|
||||
// newZipFormat creates a Zip format with proper character encoding support.
|
||||
// It uses GB18030 encoding to handle Chinese characters in filenames that may
|
||||
// be encoded with legacy GBK/GB18030 instead of UTF-8.
|
||||
func newZipFormat() archives.Zip {
|
||||
return archives.Zip{
|
||||
// GB18030 is a superset of GBK and handles Chinese characters correctly
|
||||
TextEncoding: simplifiedchinese.GB18030,
|
||||
}
|
||||
}
|
||||
|
||||
// isArchiveFile checks if a file is a supported archive format
|
||||
func isArchiveFile(filename string) bool {
|
||||
lowerName := strings.ToLower(filename)
|
||||
// Check for multi-part archive first
|
||||
if isMultiPartArchive(filename) {
|
||||
return true
|
||||
}
|
||||
return slices.ContainsFunc(supportedArchiveExtensions, func(ext string) bool {
|
||||
return strings.HasSuffix(lowerName, ext)
|
||||
})
|
||||
}
|
||||
|
||||
// archiveInfo holds information about an opened archive
|
||||
type archiveInfo struct {
|
||||
file *os.File
|
||||
stat os.FileInfo
|
||||
format archives.Format
|
||||
input io.Reader
|
||||
}
|
||||
|
||||
// openArchive opens an archive file and identifies its format
|
||||
func openArchive(archivePath string, password string) (*archiveInfo, error) {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
format, input, err := archives.Identify(context.Background(), archivePath, file)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Configure format-specific settings
|
||||
// Handle password-protected archives and character encoding
|
||||
if password != "" {
|
||||
if rar, ok := format.(archives.Rar); ok {
|
||||
rar.Password = password
|
||||
format = rar
|
||||
}
|
||||
if sz, ok := format.(archives.SevenZip); ok {
|
||||
sz.Password = password
|
||||
format = sz
|
||||
}
|
||||
}
|
||||
|
||||
// For ZIP files, configure character encoding to handle non-UTF8 filenames
|
||||
// This is essential for Chinese characters encoded in GBK/GB18030
|
||||
if _, ok := format.(archives.Zip); ok {
|
||||
format = newZipFormat()
|
||||
}
|
||||
|
||||
return &archiveInfo{
|
||||
file: file,
|
||||
stat: stat,
|
||||
format: format,
|
||||
input: input,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createExtractionHandler creates a handler function for extracting files with progress tracking
|
||||
func createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
var extractedFiles atomic.Int32
|
||||
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
err := extractFile(ctx, fileInfo, destDir)
|
||||
if err == nil && !fileInfo.IsDir() {
|
||||
extracted := int(extractedFiles.Add(1))
|
||||
if progressCallback != nil && totalFiles > 0 {
|
||||
progress := int(math.Min(float64((extracted*100)/totalFiles), 100))
|
||||
progressCallback(extracted, totalFiles, progress)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// extractArchive extracts an archive file to a destination directory
|
||||
func extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||
// Open the archive file
|
||||
info, err := openArchive(archivePath, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer info.file.Close()
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle extraction based on format type
|
||||
switch f := info.format.(type) {
|
||||
case archives.Extractor:
|
||||
// For archive formats (zip, rar, 7z, tar, etc.)
|
||||
// First, count total files for progress tracking
|
||||
totalFiles, err := countArchiveFiles(archivePath, password)
|
||||
if err != nil {
|
||||
// If counting fails, proceed without progress reporting
|
||||
totalFiles = 0
|
||||
}
|
||||
return f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||
case archives.Decompressor:
|
||||
// For single-file compression formats (gz, bz2, xz, etc.)
|
||||
// Decompress to a file without the compression extension
|
||||
baseName := filepath.Base(archivePath)
|
||||
lowerBaseName := strings.ToLower(baseName)
|
||||
for _, ext := range supportedArchiveExtensions {
|
||||
if strings.HasSuffix(lowerBaseName, ext) {
|
||||
// Get the actual suffix from the original filename (preserving case)
|
||||
actualSuffix := baseName[len(baseName)-len(ext):]
|
||||
baseName = strings.TrimSuffix(baseName, actualSuffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
destPath := filepath.Join(destDir, baseName)
|
||||
|
||||
reader, err := f.OpenReader(info.input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
// Report progress at start and end for decompression
|
||||
if progressCallback != nil {
|
||||
progressCallback(0, 1, 0)
|
||||
}
|
||||
_, err = io.Copy(destFile, reader)
|
||||
if err == nil && progressCallback != nil {
|
||||
progressCallback(1, 1, 100)
|
||||
}
|
||||
return err
|
||||
case archives.Archiver:
|
||||
// This format is an archiver, try to extract using the extractor interface
|
||||
if ext, ok := info.format.(archives.Extractor); ok {
|
||||
// Reset file position
|
||||
if seeker, ok := info.input.(io.Seeker); ok {
|
||||
seeker.Seek(0, io.SeekStart)
|
||||
}
|
||||
// Count total files for progress tracking
|
||||
totalFiles, err := countArchiveFiles(archivePath, password)
|
||||
if err != nil {
|
||||
totalFiles = 0
|
||||
}
|
||||
return ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createCountingHandler creates a handler function for counting files in an archive
|
||||
func createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
if !fileInfo.IsDir() {
|
||||
(*count)++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// countArchiveFiles counts the number of files in an archive for progress calculation
|
||||
func countArchiveFiles(archivePath string, password string) (int, error) {
|
||||
info, err := openArchive(archivePath, password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer info.file.Close()
|
||||
|
||||
count := 0
|
||||
switch f := info.format.(type) {
|
||||
case archives.Extractor:
|
||||
err = f.Extract(context.Background(), info.input, createCountingHandler(&count))
|
||||
case archives.Archiver:
|
||||
if ext, ok := info.format.(archives.Extractor); ok {
|
||||
if seeker, ok := info.input.(io.Seeker); ok {
|
||||
seeker.Seek(0, io.SeekStart)
|
||||
}
|
||||
err = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count))
|
||||
}
|
||||
case archives.Decompressor:
|
||||
// Single file compression, count as 1
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
// extractFile handles extracting a single file from an archive
|
||||
func extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error {
|
||||
// Skip directories, they will be created when extracting files
|
||||
if fileInfo.IsDir() {
|
||||
destPath := filepath.Join(destDir, fileInfo.NameInArchive)
|
||||
return os.MkdirAll(destPath, fileInfo.Mode())
|
||||
}
|
||||
|
||||
// Sanitize the path to prevent path traversal attacks
|
||||
cleanPath := filepath.Clean(fileInfo.NameInArchive)
|
||||
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
|
||||
// Skip files with suspicious paths
|
||||
return nil
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, cleanPath)
|
||||
|
||||
// Create parent directories
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the file from the archive
|
||||
reader, err := fileInfo.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Create the destination file
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
// Copy the contents
|
||||
_, err = io.Copy(destFile, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set file permissions
|
||||
return os.Chmod(destPath, fileInfo.Mode())
|
||||
}
|
||||
|
||||
// isMultiPartArchive checks if a file is part of a multi-part archive
|
||||
func isMultiPartArchive(filename string) bool {
|
||||
info := getArchivePartInfo(filename)
|
||||
return info.IsMultiPart
|
||||
}
|
||||
|
||||
// getArchivePartInfo returns detailed information about a multi-part archive file
|
||||
func getArchivePartInfo(filename string) ArchivePartInfo {
|
||||
baseName := filepath.Base(filename)
|
||||
|
||||
for _, pattern := range multiPartArchivePatterns {
|
||||
matches := pattern.FindStringSubmatch(baseName)
|
||||
if len(matches) >= 3 {
|
||||
partNum := 0
|
||||
_, err := parsePartNumber(matches[2], &partNum)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
info := ArchivePartInfo{
|
||||
IsMultiPart: true,
|
||||
BaseName: matches[1],
|
||||
PartNumber: partNum,
|
||||
Pattern: pattern.String(),
|
||||
}
|
||||
|
||||
// Determine the first part path based on pattern
|
||||
dir := filepath.Dir(filename)
|
||||
info.FirstPartPath = determineFirstPartPath(dir, info.BaseName, pattern.String())
|
||||
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .rar files that might be the first part of old-style multi-part RAR
|
||||
if strings.HasSuffix(strings.ToLower(baseName), ".rar") && !strings.Contains(strings.ToLower(baseName), ".part") {
|
||||
// Check if there are .r00, .r01 files in the same directory
|
||||
dir := filepath.Dir(filename)
|
||||
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||
r00Path := filepath.Join(dir, nameWithoutExt+".r00")
|
||||
if _, err := os.Stat(r00Path); err == nil {
|
||||
return ArchivePartInfo{
|
||||
IsMultiPart: true,
|
||||
BaseName: nameWithoutExt,
|
||||
PartNumber: 1, // .rar is the first part in old-style
|
||||
FirstPartPath: filename,
|
||||
Pattern: "rar-old-style",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ArchivePartInfo{IsMultiPart: false}
|
||||
}
|
||||
|
||||
// parsePartNumber parses a part number string and stores it in the provided pointer
|
||||
// Returns the parsed number and nil error on success
|
||||
func parsePartNumber(s string, partNum *int) (int, error) {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
// For .001, .002 style, part number is the value itself
|
||||
// For .part01 style, part number is the value itself
|
||||
// For .r00, .r01 style: r00=2 (since .rar is part 1), r01=3, etc.
|
||||
// However, for consistency, we return the raw number and handle the offset elsewhere
|
||||
*partNum = n
|
||||
// Treat 00 as part 0, let callers handle the semantics
|
||||
if n == 0 {
|
||||
*partNum = 1 // For .001 format, 001 should be 1
|
||||
}
|
||||
return *partNum, nil
|
||||
}
|
||||
|
||||
// determineFirstPartPath determines the path to the first part of a multi-part archive
|
||||
func determineFirstPartPath(dir, baseName, pattern string) string {
|
||||
switch {
|
||||
case strings.Contains(pattern, `.7z)`):
|
||||
// 7z multi-part: first part is .7z.001
|
||||
return filepath.Join(dir, baseName+".001")
|
||||
case strings.Contains(pattern, `.part`):
|
||||
// RAR new style: first part is .part01.rar or .part1.rar
|
||||
// Try both single and double digit formats
|
||||
if _, err := os.Stat(filepath.Join(dir, baseName+".part1.rar")); err == nil {
|
||||
return filepath.Join(dir, baseName+".part1.rar")
|
||||
}
|
||||
return filepath.Join(dir, baseName+".part01.rar")
|
||||
case strings.Contains(pattern, `.r(`):
|
||||
// RAR old style: first part is .rar (not .r00)
|
||||
return filepath.Join(dir, baseName+".rar")
|
||||
case strings.Contains(pattern, `.zip)`):
|
||||
// ZIP multi-part: first part is .zip.001
|
||||
return filepath.Join(dir, baseName+".001")
|
||||
case strings.Contains(pattern, `.z(`):
|
||||
// ZIP split: last part is .zip, but extraction should start from .z01
|
||||
return filepath.Join(dir, baseName+".z01")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// isFirstPart checks if the given file is the first part of a multi-part archive
|
||||
func isFirstPart(filename string) bool {
|
||||
info := getArchivePartInfo(filename)
|
||||
if !info.IsMultiPart {
|
||||
return false
|
||||
}
|
||||
|
||||
// For most formats, part 1 is the first part
|
||||
// For old-style RAR, check if this is the .rar file
|
||||
if info.Pattern == "rar-old-style" {
|
||||
return strings.HasSuffix(strings.ToLower(filename), ".rar")
|
||||
}
|
||||
|
||||
return info.PartNumber == 1
|
||||
}
|
||||
|
||||
// GetMultiPartArchiveBaseName returns the base name for a multi-part archive
|
||||
// This is used to group related parts together
|
||||
func GetMultiPartArchiveBaseName(filename string) string {
|
||||
info := getArchivePartInfo(filename)
|
||||
if !info.IsMultiPart {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(filename), info.BaseName)
|
||||
}
|
||||
|
||||
// extractMultiPartArchive extracts a multi-part archive starting from the first part
|
||||
func extractMultiPartArchive(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||
info := getArchivePartInfo(firstPartPath)
|
||||
|
||||
// For 7z multi-part archives, the bodgit/sevenzip library handles multi-volume automatically
|
||||
// when using OpenReader with a .001 file
|
||||
if strings.Contains(info.Pattern, `\.7z\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".7z.001") {
|
||||
return extractSevenZipMultiPart(firstPartPath, destDir, password, progressCallback)
|
||||
}
|
||||
|
||||
// For RAR multi-part archives, use the archives library with Name and FS fields
|
||||
if strings.Contains(info.Pattern, "rar") || info.Pattern == "rar-old-style" {
|
||||
return extractRarMultiPart(firstPartPath, destDir, password, progressCallback)
|
||||
}
|
||||
|
||||
// For ZIP multi-part archives (.zip.001, .zip.002, etc.), concatenate parts and extract
|
||||
if strings.Contains(info.Pattern, `\.zip\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".zip.001") {
|
||||
return extractZipMultiPart(firstPartPath, destDir, password, progressCallback)
|
||||
}
|
||||
|
||||
// For other formats, try standard extraction (may not work for all multi-part formats)
|
||||
return extractArchive(firstPartPath, destDir, password, progressCallback)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/bodgit/sevenzip"
|
||||
)
|
||||
|
||||
// extractSevenZipMultiPart extracts a multi-part 7z archive using bodgit/sevenzip directly
|
||||
// The mholt/archives wrapper doesn't properly handle multi-part 7z files because it uses
|
||||
// io.SectionReader which can only see the first part. The bodgit/sevenzip library's
|
||||
// OpenReaderWithPassword function handles multi-part files automatically when given the .001 file path.
|
||||
func extractSevenZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||
// Use bodgit/sevenzip directly - it automatically handles .001, .002, etc. files
|
||||
var reader *sevenzip.ReadCloser
|
||||
var err error
|
||||
|
||||
if password != "" {
|
||||
reader, err = sevenzip.OpenReaderWithPassword(firstPartPath, password)
|
||||
} else {
|
||||
reader, err = sevenzip.OpenReader(firstPartPath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Create destination directory
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Count total files for progress
|
||||
totalFiles := 0
|
||||
for _, f := range reader.File {
|
||||
if !f.FileInfo().IsDir() {
|
||||
totalFiles++
|
||||
}
|
||||
}
|
||||
|
||||
// Extract files with progress tracking
|
||||
extractedFiles := 0
|
||||
for _, f := range reader.File {
|
||||
destPath := filepath.Join(destDir, f.Name)
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(destPath, f.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract file - open, copy, close immediately (as recommended by bodgit/sevenzip)
|
||||
if err := extractSevenZipFile(f, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extractedFiles++
|
||||
if progressCallback != nil && totalFiles > 0 {
|
||||
progress := int(float64(extractedFiles) / float64(totalFiles) * 100)
|
||||
progressCallback(extractedFiles, totalFiles, progress)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractSevenZipFile extracts a single file from a 7z archive
|
||||
// This follows the bodgit/sevenzip recommended pattern of closing rc before processing the next file
|
||||
func extractSevenZipFile(f *sevenzip.File, destPath string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ExtractionJob represents a single extraction job in the queue
|
||||
type ExtractionJob struct {
|
||||
// ID is a unique identifier for this job (usually task ID or multi-part base name)
|
||||
ID string
|
||||
// Execute is the function that performs the actual extraction
|
||||
Execute func()
|
||||
// done channel is signaled when the job has been executed
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewExtractionJob creates a new extraction job
|
||||
func NewExtractionJob(id string, execute func()) *ExtractionJob {
|
||||
return &ExtractionJob{
|
||||
ID: id,
|
||||
Execute: execute,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the job has been executed
|
||||
func (j *ExtractionJob) Wait() {
|
||||
<-j.done
|
||||
}
|
||||
|
||||
// ExtractionQueue manages a queue of extraction jobs to prevent resource exhaustion
|
||||
// by ensuring only one extraction (or one multi-part archive extraction) runs at a time
|
||||
type ExtractionQueue struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
jobs []*ExtractionJob
|
||||
running bool
|
||||
shutdown bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewExtractionQueue creates a new extraction queue
|
||||
func NewExtractionQueue() *ExtractionQueue {
|
||||
q := &ExtractionQueue{
|
||||
jobs: make([]*ExtractionJob, 0),
|
||||
}
|
||||
q.cond = sync.NewCond(&q.mu)
|
||||
return q
|
||||
}
|
||||
|
||||
// Start starts the queue worker that processes jobs sequentially
|
||||
func (q *ExtractionQueue) Start() {
|
||||
q.mu.Lock()
|
||||
if q.running {
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
q.running = true
|
||||
q.shutdown = false
|
||||
q.mu.Unlock()
|
||||
|
||||
q.wg.Add(1)
|
||||
go q.worker()
|
||||
}
|
||||
|
||||
// Stop stops the queue worker gracefully
|
||||
// It waits for the current job to complete but discards pending jobs
|
||||
func (q *ExtractionQueue) Stop() {
|
||||
q.mu.Lock()
|
||||
if !q.running {
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
q.shutdown = true
|
||||
q.cond.Signal()
|
||||
q.mu.Unlock()
|
||||
|
||||
q.wg.Wait()
|
||||
}
|
||||
|
||||
// Enqueue adds a new extraction job to the queue
|
||||
// The job will be executed when its turn comes (FIFO order)
|
||||
// Returns the job so the caller can wait for completion if needed
|
||||
func (q *ExtractionQueue) Enqueue(job *ExtractionJob) *ExtractionJob {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
if q.shutdown {
|
||||
// Queue is shutting down, signal done immediately without executing
|
||||
close(job.done)
|
||||
return job
|
||||
}
|
||||
|
||||
q.jobs = append(q.jobs, job)
|
||||
q.cond.Signal()
|
||||
return job
|
||||
}
|
||||
|
||||
// EnqueueAndWait adds a new extraction job to the queue and waits for it to complete
|
||||
func (q *ExtractionQueue) EnqueueAndWait(job *ExtractionJob) {
|
||||
q.Enqueue(job)
|
||||
job.Wait()
|
||||
}
|
||||
|
||||
// QueueLength returns the current number of pending jobs in the queue
|
||||
func (q *ExtractionQueue) QueueLength() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.jobs)
|
||||
}
|
||||
|
||||
// IsRunning returns true if the queue worker is running
|
||||
func (q *ExtractionQueue) IsRunning() bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return q.running
|
||||
}
|
||||
|
||||
// HasPendingJob checks if there's a pending job with the given ID
|
||||
func (q *ExtractionQueue) HasPendingJob(id string) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
for _, job := range q.jobs {
|
||||
if job.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemovePendingJob removes a pending job with the given ID from the queue
|
||||
// Returns true if a job was removed, false if not found
|
||||
// Note: This cannot remove a job that is currently being executed
|
||||
func (q *ExtractionQueue) RemovePendingJob(id string) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
for i, job := range q.jobs {
|
||||
if job.ID == id {
|
||||
// Close the done channel to unblock any waiters
|
||||
close(job.done)
|
||||
// Remove from queue
|
||||
q.jobs = append(q.jobs[:i], q.jobs[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// worker is the main loop that processes jobs sequentially
|
||||
func (q *ExtractionQueue) worker() {
|
||||
defer q.wg.Done()
|
||||
|
||||
for {
|
||||
q.mu.Lock()
|
||||
// Wait for a job or shutdown signal
|
||||
for len(q.jobs) == 0 && !q.shutdown {
|
||||
q.cond.Wait()
|
||||
}
|
||||
|
||||
if q.shutdown {
|
||||
// Close done channels for all remaining jobs
|
||||
for _, job := range q.jobs {
|
||||
close(job.done)
|
||||
}
|
||||
q.jobs = nil
|
||||
q.running = false
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Dequeue the first job
|
||||
job := q.jobs[0]
|
||||
q.jobs = q.jobs[1:]
|
||||
q.mu.Unlock()
|
||||
|
||||
// Execute the job outside the lock
|
||||
if job.Execute != nil {
|
||||
job.Execute()
|
||||
}
|
||||
|
||||
// Signal that the job is done
|
||||
close(job.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Global extraction queue instance
|
||||
var globalExtractionQueue *ExtractionQueue
|
||||
var extractionQueueOnce sync.Once
|
||||
|
||||
// GetExtractionQueue returns the global extraction queue instance
|
||||
func GetExtractionQueue() *ExtractionQueue {
|
||||
extractionQueueOnce.Do(func() {
|
||||
globalExtractionQueue = NewExtractionQueue()
|
||||
globalExtractionQueue.Start()
|
||||
})
|
||||
return globalExtractionQueue
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewExtractionQueue(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
if q == nil {
|
||||
t.Fatal("NewExtractionQueue returned nil")
|
||||
}
|
||||
if q.jobs == nil {
|
||||
t.Fatal("jobs slice should be initialized")
|
||||
}
|
||||
if len(q.jobs) != 0 {
|
||||
t.Fatalf("expected empty jobs slice, got %d", len(q.jobs))
|
||||
}
|
||||
if q.running {
|
||||
t.Fatal("queue should not be running initially")
|
||||
}
|
||||
if q.cond == nil {
|
||||
t.Fatal("condition variable should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_StartStop(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
// Test Start
|
||||
q.Start()
|
||||
if !q.IsRunning() {
|
||||
t.Fatal("queue should be running after Start")
|
||||
}
|
||||
|
||||
// Test double Start (should be idempotent)
|
||||
q.Start()
|
||||
if !q.IsRunning() {
|
||||
t.Fatal("queue should still be running after second Start")
|
||||
}
|
||||
|
||||
// Test Stop
|
||||
q.Stop()
|
||||
if q.IsRunning() {
|
||||
t.Fatal("queue should not be running after Stop")
|
||||
}
|
||||
|
||||
// Test double Stop (should be idempotent)
|
||||
q.Stop()
|
||||
if q.IsRunning() {
|
||||
t.Fatal("queue should still not be running after second Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_EnqueueSingleJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
executed := false
|
||||
job := NewExtractionJob("test-1", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.Enqueue(job)
|
||||
job.Wait()
|
||||
|
||||
if !executed {
|
||||
t.Fatal("job should have been executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_EnqueueAndWait(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
executed := false
|
||||
job := NewExtractionJob("test-1", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.EnqueueAndWait(job)
|
||||
|
||||
if !executed {
|
||||
t.Fatal("job should have been executed after EnqueueAndWait returns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_FIFOOrder(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
// Don't start yet - we want to queue multiple jobs first
|
||||
|
||||
var executionOrder []string
|
||||
var mu sync.Mutex
|
||||
|
||||
addToOrder := func(id string) {
|
||||
mu.Lock()
|
||||
executionOrder = append(executionOrder, id)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
job1 := NewExtractionJob("job-1", func() {
|
||||
addToOrder("job-1")
|
||||
})
|
||||
job2 := NewExtractionJob("job-2", func() {
|
||||
addToOrder("job-2")
|
||||
})
|
||||
job3 := NewExtractionJob("job-3", func() {
|
||||
addToOrder("job-3")
|
||||
})
|
||||
|
||||
// Enqueue jobs before starting (they'll wait in queue)
|
||||
q.mu.Lock()
|
||||
q.jobs = append(q.jobs, job1, job2, job3)
|
||||
q.mu.Unlock()
|
||||
|
||||
// Now start processing
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
// Wait for all jobs
|
||||
job1.Wait()
|
||||
job2.Wait()
|
||||
job3.Wait()
|
||||
|
||||
// Verify FIFO order
|
||||
if len(executionOrder) != 3 {
|
||||
t.Fatalf("expected 3 executions, got %d", len(executionOrder))
|
||||
}
|
||||
if executionOrder[0] != "job-1" || executionOrder[1] != "job-2" || executionOrder[2] != "job-3" {
|
||||
t.Fatalf("expected FIFO order [job-1, job-2, job-3], got %v", executionOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_SequentialExecution(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
var activeJobs int32
|
||||
var maxConcurrent int32
|
||||
var mu sync.Mutex
|
||||
|
||||
// Create jobs that take some time to execute
|
||||
createJob := func(id string) *ExtractionJob {
|
||||
return NewExtractionJob(id, func() {
|
||||
current := atomic.AddInt32(&activeJobs, 1)
|
||||
mu.Lock()
|
||||
if current > maxConcurrent {
|
||||
maxConcurrent = current
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
atomic.AddInt32(&activeJobs, -1)
|
||||
})
|
||||
}
|
||||
|
||||
jobs := make([]*ExtractionJob, 5)
|
||||
for i := range jobs {
|
||||
jobs[i] = q.Enqueue(createJob(string(rune('A' + i))))
|
||||
}
|
||||
|
||||
// Wait for all jobs
|
||||
for _, job := range jobs {
|
||||
job.Wait()
|
||||
}
|
||||
|
||||
// Verify only one job ran at a time
|
||||
if maxConcurrent > 1 {
|
||||
t.Fatalf("expected max 1 concurrent job, got %d", maxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_QueueLength(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
if q.QueueLength() != 0 {
|
||||
t.Fatalf("expected queue length 0, got %d", q.QueueLength())
|
||||
}
|
||||
|
||||
// Add jobs without starting (they'll stay in queue)
|
||||
blockChan := make(chan struct{})
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan // Block until signaled
|
||||
})
|
||||
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
|
||||
// Give worker time to pick up the blocking job
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Queue more jobs while blocking job is running
|
||||
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
|
||||
job3 := q.Enqueue(NewExtractionJob("job-3", func() {}))
|
||||
|
||||
// Should have 2 jobs waiting
|
||||
if q.QueueLength() != 2 {
|
||||
t.Fatalf("expected queue length 2, got %d", q.QueueLength())
|
||||
}
|
||||
|
||||
// Unblock and wait
|
||||
close(blockChan)
|
||||
blockingJob.Wait()
|
||||
job2.Wait()
|
||||
job3.Wait()
|
||||
|
||||
if q.QueueLength() != 0 {
|
||||
t.Fatalf("expected queue length 0 after completion, got %d", q.QueueLength())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_HasPendingJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
blockChan := make(chan struct{})
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan
|
||||
})
|
||||
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
||||
|
||||
// Queue more jobs
|
||||
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
|
||||
|
||||
if !q.HasPendingJob("job-2") {
|
||||
t.Fatal("expected HasPendingJob to return true for job-2")
|
||||
}
|
||||
|
||||
if q.HasPendingJob("job-99") {
|
||||
t.Fatal("expected HasPendingJob to return false for non-existent job")
|
||||
}
|
||||
|
||||
// Unblock and wait
|
||||
close(blockChan)
|
||||
blockingJob.Wait()
|
||||
job2.Wait()
|
||||
|
||||
if q.HasPendingJob("job-2") {
|
||||
t.Fatal("expected HasPendingJob to return false after job completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_RemovePendingJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
blockChan := make(chan struct{})
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan
|
||||
})
|
||||
|
||||
executed := false
|
||||
job2 := NewExtractionJob("job-2", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
||||
q.Enqueue(job2)
|
||||
|
||||
// Remove job-2 before it executes
|
||||
removed := q.RemovePendingJob("job-2")
|
||||
if !removed {
|
||||
t.Fatal("expected RemovePendingJob to return true")
|
||||
}
|
||||
|
||||
// Try to remove again (should return false)
|
||||
removed = q.RemovePendingJob("job-2")
|
||||
if removed {
|
||||
t.Fatal("expected second RemovePendingJob to return false")
|
||||
}
|
||||
|
||||
// Unblock
|
||||
close(blockChan)
|
||||
blockingJob.Wait()
|
||||
|
||||
// Wait a bit for any potential execution
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if executed {
|
||||
t.Fatal("removed job should not have been executed")
|
||||
}
|
||||
|
||||
// The removed job's done channel should be closed
|
||||
select {
|
||||
case <-job2.done:
|
||||
// Expected
|
||||
default:
|
||||
t.Fatal("removed job's done channel should be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_StopDiscardsPendingJobs(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
blockChan := make(chan struct{})
|
||||
executed1 := false
|
||||
executed2 := false
|
||||
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan
|
||||
executed1 = true
|
||||
})
|
||||
pendingJob := NewExtractionJob("pending", func() {
|
||||
executed2 = true
|
||||
})
|
||||
|
||||
q.Start()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
||||
q.Enqueue(pendingJob)
|
||||
|
||||
// Stop without unblocking
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
close(blockChan)
|
||||
}()
|
||||
q.Stop()
|
||||
|
||||
// Blocking job should have completed, but pending job was discarded
|
||||
if !executed1 {
|
||||
t.Log("Note: blocking job may not complete if Stop() won races")
|
||||
}
|
||||
if executed2 {
|
||||
t.Fatal("pending job should not have been executed after Stop")
|
||||
}
|
||||
|
||||
// Pending job's done channel should be closed
|
||||
select {
|
||||
case <-pendingJob.done:
|
||||
// Expected
|
||||
default:
|
||||
t.Fatal("pending job's done channel should be closed after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_EnqueueAfterShutdown(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
q.Stop()
|
||||
|
||||
executed := false
|
||||
job := NewExtractionJob("after-shutdown", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.Enqueue(job)
|
||||
|
||||
// Job should be immediately done (without execution)
|
||||
select {
|
||||
case <-job.done:
|
||||
// Expected
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("job should be immediately done after shutdown")
|
||||
}
|
||||
|
||||
if executed {
|
||||
t.Fatal("job should not be executed after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExtractionJob(t *testing.T) {
|
||||
job := NewExtractionJob("test-id", func() {})
|
||||
|
||||
if job.ID != "test-id" {
|
||||
t.Fatalf("expected ID 'test-id', got '%s'", job.ID)
|
||||
}
|
||||
if job.Execute == nil {
|
||||
t.Fatal("Execute should not be nil")
|
||||
}
|
||||
if job.done == nil {
|
||||
t.Fatal("done channel should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionJob_Wait(t *testing.T) {
|
||||
job := NewExtractionJob("test", func() {})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
job.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Wait should block
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Wait should block until job is done")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Close done channel
|
||||
close(job.done)
|
||||
|
||||
// Now Wait should return
|
||||
select {
|
||||
case <-done:
|
||||
// Expected
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Wait should return after job is done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_NilExecuteFunction(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
// Job with nil Execute should not panic
|
||||
job := &ExtractionJob{
|
||||
ID: "nil-execute",
|
||||
Execute: nil,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
q.Enqueue(job)
|
||||
job.Wait()
|
||||
// No panic means success
|
||||
}
|
||||
|
||||
func TestExtractionQueue_ConcurrentEnqueue(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
var counter int32
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 10
|
||||
jobsPerGoroutine := 10
|
||||
|
||||
wg.Add(numGoroutines)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < jobsPerGoroutine; j++ {
|
||||
job := NewExtractionJob("", func() {
|
||||
atomic.AddInt32(&counter, 1)
|
||||
})
|
||||
q.EnqueueAndWait(job)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
expected := int32(numGoroutines * jobsPerGoroutine)
|
||||
if counter != expected {
|
||||
t.Fatalf("expected counter %d, got %d", expected, counter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_Restart(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
// First run
|
||||
q.Start()
|
||||
executed1 := false
|
||||
job1 := NewExtractionJob("job-1", func() {
|
||||
executed1 = true
|
||||
})
|
||||
q.EnqueueAndWait(job1)
|
||||
q.Stop()
|
||||
|
||||
if !executed1 {
|
||||
t.Fatal("first job should have been executed")
|
||||
}
|
||||
|
||||
// Restart
|
||||
q.Start()
|
||||
executed2 := false
|
||||
job2 := NewExtractionJob("job-2", func() {
|
||||
executed2 = true
|
||||
})
|
||||
q.EnqueueAndWait(job2)
|
||||
q.Stop()
|
||||
|
||||
if !executed2 {
|
||||
t.Fatal("second job should have been executed after restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExtractionQueue(t *testing.T) {
|
||||
// Note: This test modifies global state, so it should ideally be run in isolation
|
||||
// However, the implementation ensures the queue is created only once
|
||||
|
||||
queue := GetExtractionQueue()
|
||||
if queue == nil {
|
||||
t.Fatal("GetExtractionQueue should not return nil")
|
||||
}
|
||||
if !queue.IsRunning() {
|
||||
t.Fatal("global queue should be running")
|
||||
}
|
||||
|
||||
// Calling again should return the same instance
|
||||
queue2 := GetExtractionQueue()
|
||||
if queue != queue2 {
|
||||
t.Fatal("GetExtractionQueue should return the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_LongRunningJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
startTime := time.Now()
|
||||
longJobDuration := 100 * time.Millisecond
|
||||
|
||||
longJob := NewExtractionJob("long-job", func() {
|
||||
time.Sleep(longJobDuration)
|
||||
})
|
||||
|
||||
shortJobExecuted := false
|
||||
shortJob := NewExtractionJob("short-job", func() {
|
||||
shortJobExecuted = true
|
||||
})
|
||||
|
||||
q.Enqueue(longJob)
|
||||
q.Enqueue(shortJob)
|
||||
|
||||
shortJob.Wait()
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed < longJobDuration {
|
||||
t.Fatalf("short job should wait for long job, elapsed: %v", elapsed)
|
||||
}
|
||||
|
||||
if !shortJobExecuted {
|
||||
t.Fatal("short job should have been executed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
)
|
||||
|
||||
// extractRarMultiPart extracts a multi-part RAR archive
|
||||
func extractRarMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||
// For RAR archives, we need to use the Name field in archives.Rar
|
||||
// to let it automatically find subsequent volumes
|
||||
|
||||
dir := filepath.Dir(firstPartPath)
|
||||
fileName := filepath.Base(firstPartPath)
|
||||
|
||||
rar := archives.Rar{
|
||||
Password: password,
|
||||
Name: fileName,
|
||||
FS: os.DirFS(dir),
|
||||
}
|
||||
|
||||
// Create destination directory
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Count files first for progress
|
||||
totalFiles := 0
|
||||
err := rar.Extract(context.Background(), nil, func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
if !fileInfo.IsDir() {
|
||||
totalFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// If counting fails, proceed without progress
|
||||
totalFiles = 0
|
||||
}
|
||||
|
||||
// Reset and extract with progress tracking
|
||||
return rar.Extract(context.Background(), nil, createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,191 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
)
|
||||
|
||||
// extractZipMultiPart extracts a multi-part ZIP archive (.zip.001, .zip.002, etc.)
|
||||
// These are created by simply splitting a ZIP file into chunks, so we concatenate them
|
||||
func extractZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||
// Find all parts
|
||||
parts, err := findZipMultiParts(firstPartPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a multi-part reader that reads across all parts
|
||||
multiReader := newMultiPartFileReader(parts)
|
||||
defer multiReader.Close()
|
||||
|
||||
// Get total size
|
||||
totalSize := multiReader.Size()
|
||||
|
||||
// Create destination directory
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// First pass: count files for progress
|
||||
totalFiles := 0
|
||||
zip := newZipFormat()
|
||||
err = zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
if !fileInfo.IsDir() {
|
||||
totalFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// If counting fails, proceed without progress
|
||||
totalFiles = 0
|
||||
}
|
||||
|
||||
// Reset reader for actual extraction
|
||||
multiReader.Close()
|
||||
multiReader = newMultiPartFileReader(parts)
|
||||
defer multiReader.Close()
|
||||
|
||||
// Second pass: extract with progress tracking
|
||||
return zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||
}
|
||||
|
||||
// findZipMultiParts finds all parts of a multi-part ZIP archive in order
|
||||
func findZipMultiParts(firstPartPath string) ([]string, error) {
|
||||
// Extract base name (e.g., "Archive.zip" from "Archive.zip.001")
|
||||
dir := filepath.Dir(firstPartPath)
|
||||
baseName := filepath.Base(firstPartPath)
|
||||
|
||||
// Remove the .001 suffix to get the base
|
||||
if idx := strings.LastIndex(baseName, "."); idx > 0 {
|
||||
baseName = baseName[:idx] // "Archive.zip"
|
||||
}
|
||||
|
||||
var parts []string
|
||||
partNum := 1
|
||||
|
||||
for {
|
||||
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".%03d", partNum))
|
||||
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
parts = append(parts, partPath)
|
||||
partNum++
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("no parts found for %s", firstPartPath)
|
||||
}
|
||||
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// multiPartFileReader provides io.ReaderAt over multiple files concatenated
|
||||
type multiPartFileReader struct {
|
||||
parts []string
|
||||
files []*os.File
|
||||
sizes []int64
|
||||
offsets []int64 // cumulative offsets for each file
|
||||
totalSize int64
|
||||
}
|
||||
|
||||
func newMultiPartFileReader(parts []string) *multiPartFileReader {
|
||||
return &multiPartFileReader{parts: parts}
|
||||
}
|
||||
|
||||
func (m *multiPartFileReader) init() error {
|
||||
if m.files != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.files = make([]*os.File, len(m.parts))
|
||||
m.sizes = make([]int64, len(m.parts))
|
||||
m.offsets = make([]int64, len(m.parts))
|
||||
|
||||
var offset int64
|
||||
for i, part := range m.parts {
|
||||
f, err := os.Open(part)
|
||||
if err != nil {
|
||||
m.Close()
|
||||
return err
|
||||
}
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
m.Close()
|
||||
return err
|
||||
}
|
||||
m.files[i] = f
|
||||
m.sizes[i] = stat.Size()
|
||||
m.offsets[i] = offset
|
||||
offset += stat.Size()
|
||||
}
|
||||
m.totalSize = offset
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *multiPartFileReader) Size() int64 {
|
||||
if err := m.init(); err != nil {
|
||||
return 0
|
||||
}
|
||||
return m.totalSize
|
||||
}
|
||||
|
||||
func (m *multiPartFileReader) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
if err := m.init(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if off >= m.totalSize {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// Find which file(s) to read from
|
||||
for i, fileOffset := range m.offsets {
|
||||
fileEnd := fileOffset + m.sizes[i]
|
||||
|
||||
if off >= fileEnd {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read from this file
|
||||
localOffset := off - fileOffset
|
||||
toRead := len(p) - n
|
||||
if int64(toRead) > fileEnd-off {
|
||||
toRead = int(fileEnd - off)
|
||||
}
|
||||
|
||||
read, err := m.files[i].ReadAt(p[n:n+toRead], localOffset)
|
||||
n += read
|
||||
off += int64(read)
|
||||
|
||||
if err != nil && err != io.EOF {
|
||||
return n, err
|
||||
}
|
||||
|
||||
if n >= len(p) {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (m *multiPartFileReader) Close() error {
|
||||
for _, f := range m.files {
|
||||
if f != nil {
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
m.files = nil
|
||||
return nil
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||||
@@ -9,8 +12,6 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ResolveResult struct {
|
||||
@@ -137,10 +138,10 @@ type DownloaderConfig struct {
|
||||
Controller *controller.Controller
|
||||
FetchManagers []fetcher.FetcherManager
|
||||
|
||||
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
|
||||
Storage Storage
|
||||
StorageDir string
|
||||
DownloadDirWhiteList []string `json:"downloadDirWhiteList"`
|
||||
RefreshInterval int // RefreshInterval time duration to refresh task progress(ms)
|
||||
Storage Storage
|
||||
StorageDir string
|
||||
WhiteDownloadDirs []string
|
||||
|
||||
ProductionMode bool
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScriptEvent represents the type of script event
|
||||
type ScriptEvent string
|
||||
|
||||
const (
|
||||
ScriptEventDownloadDone ScriptEvent = "DOWNLOAD_DONE"
|
||||
ScriptEventDownloadError ScriptEvent = "DOWNLOAD_ERROR"
|
||||
)
|
||||
|
||||
// ScriptData is the internal data structure for passing script information
|
||||
type ScriptData struct {
|
||||
Event ScriptEvent
|
||||
Time int64 // Unix timestamp in milliseconds
|
||||
Payload *ScriptPayload
|
||||
}
|
||||
|
||||
// ScriptPayload contains the task data
|
||||
type ScriptPayload struct {
|
||||
Task *Task
|
||||
}
|
||||
|
||||
// getScriptPaths extracts script paths from config
|
||||
func (d *Downloader) getScriptPaths() []string {
|
||||
cfg := d.cfg.DownloaderStoreConfig
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check new script config
|
||||
if cfg.Script != nil && cfg.Script.Enable && len(cfg.Script.Paths) > 0 {
|
||||
paths := make([]string, 0, len(cfg.Script.Paths))
|
||||
for _, path := range cfg.Script.Paths {
|
||||
if path != "" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
if len(paths) > 0 {
|
||||
return paths
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeScriptAtPath executes a single script with the given data
|
||||
// Returns any error that occurred during execution
|
||||
func (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) error {
|
||||
if scriptPath == "" {
|
||||
return fmt.Errorf("script path is empty")
|
||||
}
|
||||
|
||||
// Check if script file exists
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("script file does not exist: %s", scriptPath)
|
||||
}
|
||||
|
||||
// Determine the script interpreter based on file extension
|
||||
var cmd *exec.Cmd
|
||||
ext := filepath.Ext(scriptPath)
|
||||
|
||||
switch ext {
|
||||
case ".sh", ".bash":
|
||||
cmd = exec.Command("bash", scriptPath)
|
||||
case ".py":
|
||||
cmd = exec.Command("python3", scriptPath)
|
||||
case ".js":
|
||||
cmd = exec.Command("node", scriptPath)
|
||||
case ".bat", ".cmd":
|
||||
// Windows batch files
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/c", scriptPath)
|
||||
} else {
|
||||
// Batch files are Windows-specific
|
||||
return fmt.Errorf("batch files (.bat/.cmd) are only supported on Windows")
|
||||
}
|
||||
case ".ps1":
|
||||
// PowerShell scripts
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
|
||||
} else {
|
||||
// Try pwsh (PowerShell Core) on non-Windows systems
|
||||
cmd = exec.Command("pwsh", "-File", scriptPath)
|
||||
}
|
||||
case "":
|
||||
// No extension, try to execute directly (assumes shebang or executable)
|
||||
cmd = exec.Command(scriptPath)
|
||||
default:
|
||||
// Unknown extension, try to execute directly
|
||||
cmd = exec.Command(scriptPath)
|
||||
}
|
||||
|
||||
// Set environment variables with task information
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("GOPEED_EVENT=%s", data.Event),
|
||||
fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID),
|
||||
fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.Task.Name()),
|
||||
fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status),
|
||||
)
|
||||
|
||||
// Add task path using the same logic as task deletion
|
||||
if data.Payload.Task.Meta != nil && data.Payload.Task.Meta.Res != nil {
|
||||
var taskPath string
|
||||
if data.Payload.Task.Meta.Res.Name != "" {
|
||||
// Multi-file task (folder)
|
||||
taskPath = data.Payload.Task.Meta.FolderPath()
|
||||
} else {
|
||||
// Single file task
|
||||
taskPath = data.Payload.Task.Meta.SingleFilepath()
|
||||
}
|
||||
cmd.Env = append(cmd.Env,
|
||||
fmt.Sprintf("GOPEED_TASK_PATH=%s", taskPath),
|
||||
)
|
||||
}
|
||||
|
||||
// Start and wait for the command to complete (no timeout)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// triggerScripts executes all configured scripts
|
||||
func (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) {
|
||||
paths := d.getScriptPaths()
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
data := &ScriptData{
|
||||
Event: event,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &ScriptPayload{
|
||||
Task: task.clone(),
|
||||
},
|
||||
}
|
||||
|
||||
go d.executeScripts(paths, data)
|
||||
}
|
||||
|
||||
func (d *Downloader) executeScripts(paths []string, data *ScriptData) {
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
go func(scriptPath string) {
|
||||
err := d.executeScriptAtPath(scriptPath, data)
|
||||
if err != nil {
|
||||
d.Logger.Warn().Err(err).Str("path", scriptPath).Msg("script: failed to execute")
|
||||
return
|
||||
}
|
||||
d.Logger.Debug().Str("path", scriptPath).Msg("script: executed successfully")
|
||||
}(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestScript_NoScriptConfigured(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger script (should not panic with no scripts configured)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_EmptyConfig(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_NoScriptConfig(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = nil
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_DisabledScript(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: false,
|
||||
Paths: []string{"/path/to/script.sh"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil for disabled script, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_EmptyPaths(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil for empty paths, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_WithEmptyStrings(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{"/path/to/script1.sh", "", "/path/to/script2.sh", ""},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if len(paths) != 2 {
|
||||
t.Errorf("Expected 2 valid paths (ignoring empty strings), got %d: %v", len(paths), paths)
|
||||
}
|
||||
if paths[0] != "/path/to/script1.sh" || paths[1] != "/path/to/script2.sh" {
|
||||
t.Errorf("Paths don't match expected values: %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_ExecuteScriptAtPath_EmptyPath(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
data := &ScriptData{
|
||||
Event: ScriptEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err := downloader.executeScriptAtPath("", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty path")
|
||||
}
|
||||
if err.Error() != "script path is empty" {
|
||||
t.Errorf("Expected 'script path is empty' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
data := &ScriptData{
|
||||
Event: ScriptEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err := downloader.executeScriptAtPath("/non/existent/script.sh", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent script")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {
|
||||
t.Helper()
|
||||
content := []byte("downloaded file")
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Status = base.DownloadStatusDone
|
||||
task.Meta = &fetcher.FetcherMeta{
|
||||
Req: &base.Request{
|
||||
URL: "https://example.com/" + fileName,
|
||||
},
|
||||
Opts: &base.Options{
|
||||
Name: fileName,
|
||||
Path: filepath.ToSlash(downloadDir),
|
||||
},
|
||||
Res: &base.Resource{
|
||||
Size: int64(len(content)),
|
||||
Files: []*base.FileInfo{
|
||||
{Name: fileName, Size: int64(len(content))},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filePath := task.Meta.SingleFilepath()
|
||||
filePathOS := filepath.FromSlash(filePath)
|
||||
if err := os.MkdirAll(filepath.Dir(filePathOS), 0755); err != nil {
|
||||
t.Fatalf("Failed to create download dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filePathOS, content, 0644); err != nil {
|
||||
t.Fatalf("Failed to create download file: %v", err)
|
||||
}
|
||||
return task, filePath
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Timeout waiting for file: %s", path)
|
||||
}
|
||||
|
||||
func getTestScriptPath(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", "scripts", name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("Missing test script %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func ensureScriptExecutable(t *testing.T, scriptPath string) {
|
||||
t.Helper()
|
||||
if filepath.Ext(scriptPath) != ".sh" {
|
||||
return
|
||||
}
|
||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||
t.Fatalf("Failed to chmod script: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package download
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
destDir := filepath.Join(tmpDir, "moved")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
|
||||
srcPath := filepath.FromSlash(taskPath)
|
||||
destFile := filepath.Join(destDir, "test.txt")
|
||||
|
||||
scriptPath := getTestScriptPath(t, "move.sh")
|
||||
ensureScriptExecutable(t, scriptPath)
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, destFile, 3*time.Second)
|
||||
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
|
||||
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_MultipleScripts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile1 := filepath.Join(tmpDir, "output1.txt")
|
||||
outputFile2 := filepath.Join(tmpDir, "output2.txt")
|
||||
scriptPath1 := getTestScriptPath(t, "write_output1.sh")
|
||||
ensureScriptExecutable(t, scriptPath1)
|
||||
scriptPath2 := getTestScriptPath(t, "write_output2.sh")
|
||||
ensureScriptExecutable(t, scriptPath2)
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath1, scriptPath2},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile1, 3*time.Second)
|
||||
waitForFile(t, outputFile2, 3*time.Second)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_EnvironmentVariables(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := getTestScriptPath(t, "env_dump.sh")
|
||||
ensureScriptExecutable(t, scriptPath)
|
||||
outputFile := filepath.Join(tmpDir, "env_output.txt")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile, 3*time.Second)
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
|
||||
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
|
||||
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
|
||||
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
|
||||
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package download
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
destDir := filepath.Join(tmpDir, "moved")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
|
||||
srcPath := filepath.FromSlash(taskPath)
|
||||
destFile := filepath.Join(destDir, "test.txt")
|
||||
|
||||
scriptPath := getTestScriptPath(t, "move.bat")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, destFile, 5*time.Second)
|
||||
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
|
||||
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_MultipleScripts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile1 := filepath.Join(tmpDir, "output1.txt")
|
||||
outputFile2 := filepath.Join(tmpDir, "output2.txt")
|
||||
scriptPath1 := getTestScriptPath(t, "write_output1.bat")
|
||||
scriptPath2 := getTestScriptPath(t, "write_output2.bat")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath1, scriptPath2},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile1, 5*time.Second)
|
||||
waitForFile(t, outputFile2, 5*time.Second)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_EnvironmentVariables(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := getTestScriptPath(t, "env_dump.bat")
|
||||
outputFile := filepath.Join(tmpDir, "env_output.txt")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile, 5*time.Second)
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
|
||||
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
|
||||
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
|
||||
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
|
||||
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
+14
-11
@@ -2,11 +2,12 @@ package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go.etcd.io/bbolt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
@@ -45,22 +46,24 @@ func changeValue(p any, v any) {
|
||||
}
|
||||
}
|
||||
|
||||
var memData = make(map[string]map[string]any)
|
||||
|
||||
type MemStorage struct {
|
||||
lock *sync.RWMutex
|
||||
data map[string]map[string]any
|
||||
}
|
||||
|
||||
func NewMemStorage() *MemStorage {
|
||||
return &MemStorage{
|
||||
lock: &sync.RWMutex{},
|
||||
data: make(map[string]map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *MemStorage) Setup(buckets []string) error {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
for _, bucket := range buckets {
|
||||
if _, ok := memData[bucket]; !ok {
|
||||
memData[bucket] = make(map[string]any)
|
||||
if _, ok := n.data[bucket]; !ok {
|
||||
n.data[bucket] = make(map[string]any)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -69,7 +72,7 @@ func (n *MemStorage) Setup(buckets []string) error {
|
||||
func (n *MemStorage) Put(bucket string, key string, v any) error {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
if bucketData, ok := memData[bucket]; ok {
|
||||
if bucketData, ok := n.data[bucket]; ok {
|
||||
bucketData[key] = v
|
||||
}
|
||||
return nil
|
||||
@@ -78,7 +81,7 @@ func (n *MemStorage) Put(bucket string, key string, v any) error {
|
||||
func (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {
|
||||
n.lock.RLock()
|
||||
defer n.lock.RUnlock()
|
||||
if dv, ok := memData[bucket][key]; ok {
|
||||
if dv, ok := n.data[bucket][key]; ok {
|
||||
changeValue(v, dv)
|
||||
return true, nil
|
||||
}
|
||||
@@ -88,7 +91,7 @@ func (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {
|
||||
func (n *MemStorage) List(bucket string, v any) error {
|
||||
n.lock.RLock()
|
||||
defer n.lock.RUnlock()
|
||||
data := memData[bucket]
|
||||
data := n.data[bucket]
|
||||
list := make([]any, 0)
|
||||
for _, v := range data {
|
||||
list = append(list, v)
|
||||
@@ -100,7 +103,7 @@ func (n *MemStorage) List(bucket string, v any) error {
|
||||
func (n *MemStorage) Pop(bucket string, key string, v any) error {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
data := memData[bucket]
|
||||
data := n.data[bucket]
|
||||
changeValue(v, data[key])
|
||||
delete(data, key)
|
||||
return nil
|
||||
@@ -109,7 +112,7 @@ func (n *MemStorage) Pop(bucket string, key string, v any) error {
|
||||
func (n *MemStorage) Delete(bucket string, key string) error {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
delete(memData[bucket], key)
|
||||
delete(n.data[bucket], key)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -120,7 +123,7 @@ func (n *MemStorage) Close() error {
|
||||
func (n *MemStorage) Clear() error {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
memData = make(map[string]map[string]any)
|
||||
n.data = make(map[string]map[string]any)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_OUTPUT_FILE%"=="" exit /b 2
|
||||
echo GOPEED_EVENT=%GOPEED_EVENT% > "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_ID=%GOPEED_TASK_ID% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_NAME=%GOPEED_TASK_NAME% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_STATUS=%GOPEED_TASK_STATUS% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_PATH=%GOPEED_TASK_PATH% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE" ]; then
|
||||
echo "GOPEED_TEST_OUTPUT_FILE is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "GOPEED_EVENT=$GOPEED_EVENT" > "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_ID=$GOPEED_TASK_ID" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_NAME=$GOPEED_TASK_NAME" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_STATUS=$GOPEED_TASK_STATUS" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_PATH=$GOPEED_TASK_PATH" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_DEST_DIR%"=="" exit /b 2
|
||||
set "SRC=%GOPEED_TASK_PATH:/=\%"
|
||||
set "DEST=%GOPEED_TEST_DEST_DIR%"
|
||||
if not exist "%DEST%" mkdir "%DEST%"
|
||||
move /Y "%SRC%" "%DEST%\" >nul
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_DEST_DIR" ]; then
|
||||
echo "GOPEED_TEST_DEST_DIR is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$GOPEED_TEST_DEST_DIR"
|
||||
mv "$GOPEED_TASK_PATH" "$GOPEED_TEST_DEST_DIR/"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_OUTPUT_FILE_1%"=="" exit /b 2
|
||||
echo Script 1 > "%GOPEED_TEST_OUTPUT_FILE_1%"
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE_1" ]; then
|
||||
echo "GOPEED_TEST_OUTPUT_FILE_1 is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "Script 1" > "$GOPEED_TEST_OUTPUT_FILE_1"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_OUTPUT_FILE_2%"=="" exit /b 2
|
||||
echo Script 2 > "%GOPEED_TEST_OUTPUT_FILE_2%"
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE_2" ]; then
|
||||
echo "GOPEED_TEST_OUTPUT_FILE_2 is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "Script 2" > "$GOPEED_TEST_OUTPUT_FILE_2"
|
||||
@@ -0,0 +1,229 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// WebhookEvent represents the type of webhook event
|
||||
type WebhookEvent string
|
||||
|
||||
const (
|
||||
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
|
||||
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
|
||||
)
|
||||
|
||||
// WebhookData is the data sent to webhook URLs
|
||||
type WebhookData struct {
|
||||
Event WebhookEvent `json:"event"`
|
||||
Time int64 `json:"time"` // Unix timestamp in milliseconds
|
||||
Payload *WebhookPayload `json:"payload"`
|
||||
}
|
||||
|
||||
// WebhookPayload contains the task data
|
||||
type WebhookPayload struct {
|
||||
Task *Task `json:"task"`
|
||||
}
|
||||
|
||||
// getWebhookUrls extracts webhook URLs from config
|
||||
// Supports both new webhook config format and legacy extra field for backward compatibility
|
||||
func (d *Downloader) getWebhookUrls() []string {
|
||||
cfg := d.cfg.DownloaderStoreConfig
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try new webhook config first
|
||||
if cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 {
|
||||
urls := make([]string, 0, len(cfg.Webhook.URLs))
|
||||
for _, url := range cfg.Webhook.URLs {
|
||||
if url != "" {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
if len(urls) > 0 {
|
||||
return urls
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy extra field for backward compatibility
|
||||
if cfg.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
webhookUrls, ok := cfg.Extra["webhookUrls"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try direct string slice first
|
||||
if urlsStr, ok := webhookUrls.([]string); ok {
|
||||
if len(urlsStr) == 0 {
|
||||
return nil
|
||||
}
|
||||
return urlsStr
|
||||
}
|
||||
|
||||
// Convert []interface{} to []string
|
||||
if urlsInterface, ok := webhookUrls.([]any); ok {
|
||||
if len(urlsInterface) == 0 {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(urlsInterface))
|
||||
for _, urlInterface := range urlsInterface {
|
||||
if url, ok := urlInterface.(string); ok && url != "" {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendWebhookToUrl sends webhook data to a single URL
|
||||
// Returns the HTTP status code and any error that occurred
|
||||
func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {
|
||||
if url == "" {
|
||||
return 0, fmt.Errorf("webhook URL is empty")
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: webhookTimeout,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Gopeed-Webhook/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// triggerWebhooks sends webhook notifications to all configured URLs
|
||||
func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {
|
||||
urls := d.getWebhookUrls()
|
||||
if len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
data := &WebhookData{
|
||||
Event: event,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &WebhookPayload{
|
||||
Task: task.clone(),
|
||||
},
|
||||
}
|
||||
|
||||
go d.sendWebhooks(urls, data)
|
||||
}
|
||||
|
||||
func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {
|
||||
for _, url := range urls {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
go func(webhookUrl string) {
|
||||
statusCode, err := d.sendWebhookToUrl(webhookUrl, data)
|
||||
if err != nil {
|
||||
d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request")
|
||||
return
|
||||
}
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully")
|
||||
} else {
|
||||
d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status")
|
||||
}
|
||||
}(url)
|
||||
}
|
||||
}
|
||||
|
||||
// SendTestWebhook sends a test webhook with a simulated payload
|
||||
// Returns error if any webhook URL does not respond with HTTP 200
|
||||
func (d *Downloader) SendTestWebhook() error {
|
||||
urls := d.getWebhookUrls()
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, url := range urls {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if err := d.TestWebhookUrl(url); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestWebhookUrl tests a single webhook URL with a simulated payload
|
||||
// Returns error if the URL does not respond with HTTP 200
|
||||
func (d *Downloader) TestWebhookUrl(url string) error {
|
||||
// Create a simulated test task with minimal required fields
|
||||
testTask := NewTask()
|
||||
testTask.Protocol = "http"
|
||||
testTask.Status = base.DownloadStatusDone
|
||||
testTask.Meta = &fetcher.FetcherMeta{
|
||||
Req: &base.Request{
|
||||
URL: "https://example.com/test-file.zip",
|
||||
},
|
||||
Opts: &base.Options{
|
||||
Name: "test-file.zip",
|
||||
Path: "/downloads",
|
||||
},
|
||||
Res: &base.Resource{
|
||||
Size: 1024 * 1024 * 100, // 100MB
|
||||
Files: []*base.FileInfo{
|
||||
{Name: "test-file.zip", Size: 1024 * 1024 * 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create test data
|
||||
testData := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &WebhookPayload{
|
||||
Task: testTask,
|
||||
},
|
||||
}
|
||||
|
||||
statusCode, err := d.sendWebhookToUrl(url, testData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
var mockFetcherMeta = fetcher.FetcherMeta{
|
||||
Req: &base.Request{
|
||||
URL: "https://example.com/test.zip",
|
||||
},
|
||||
Opts: &base.Options{
|
||||
Path: "/downloads",
|
||||
},
|
||||
Res: &base.Resource{
|
||||
Size: 1024 * 1024,
|
||||
Files: []*base.FileInfo{
|
||||
{Name: "test.zip", Size: 1024 * 1024},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestWebhook_TriggerOnDone(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
} else if data.Payload.Task.ID != task.ID {
|
||||
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
|
||||
}
|
||||
if data.Time == 0 {
|
||||
t.Error("Expected time to be set")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TriggerOnError(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook with error
|
||||
testError := http.ErrServerClosed
|
||||
downloader.triggerWebhooks(WebhookEventDownloadError, task, testError)
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadError {
|
||||
t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendTestWebhook(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Send test webhook
|
||||
err := downloader.SendTestWebhook()
|
||||
if err != nil {
|
||||
t.Errorf("SendTestWebhook failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
}
|
||||
if data.Time == 0 {
|
||||
t.Error("Expected time to be set")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_NoWebhookConfigured(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook (should not panic with no webhooks configured)
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
// Send test webhook (should not panic)
|
||||
err := downloader.SendTestWebhook()
|
||||
if err != nil {
|
||||
t.Errorf("SendTestWebhook failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_MultipleUrls(t *testing.T) {
|
||||
count := 0
|
||||
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server1.Close()
|
||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server2.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure multiple webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server1.URL, server2.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
// Wait for webhooks
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 webhook calls, got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {
|
||||
// Test that SendTestWebhook returns error for non-200 status codes
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError) // 500
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Send test webhook - should fail with non-200 status
|
||||
err := downloader.SendTestWebhook()
|
||||
if err == nil {
|
||||
t.Error("Expected SendTestWebhook to return error for non-200 status")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookFailsOn201(t *testing.T) {
|
||||
// Test that SendTestWebhook returns error for 201 (only 200 is success)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated) // 201
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Send test webhook - should fail with 201 status (only 200 is success)
|
||||
err := downloader.SendTestWebhook()
|
||||
if err == nil {
|
||||
t.Error("Expected SendTestWebhook to return error for 201 status")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrl(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Test single URL
|
||||
err := downloader.TestWebhookUrl(server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("TestWebhookUrl failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrlEmpty(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Test with empty URL - should return error
|
||||
err := downloader.TestWebhookUrl("")
|
||||
if err == nil {
|
||||
t.Error("Expected TestWebhookUrl to return error for empty URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = nil
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{Enable: true} // No URLs set
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"http://example.com", "http://example2.com"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("Expected 2 URLs, got %d", len(urls))
|
||||
}
|
||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
||||
t.Errorf("URLs don't match expected values: %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"http://example.com", "http://example2.com"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("Expected 2 URLs, got %d", len(urls))
|
||||
}
|
||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
||||
t.Errorf("URLs don't match expected values: %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Extra = make(map[string]any)
|
||||
cfg.Webhook.URLs = []string{}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"", "", ""},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil for all empty strings, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"http://example.com", "", "http://example2.com", ""},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("Expected 2 valid URLs (ignoring empty strings), got %d: %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
||||
t.Errorf("URLs don't match expected values: %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{Enable: false} // Disabled webhook
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil for disabled webhook, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_DisabledWebhook(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: false,
|
||||
URLs: []string{"http://example.com"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil for disabled webhook even with URLs, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_Success(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify headers
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" {
|
||||
t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &WebhookPayload{
|
||||
Task: task,
|
||||
},
|
||||
}
|
||||
|
||||
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
|
||||
if err != nil {
|
||||
t.Errorf("sendWebhookToUrl failed: %v", err)
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", statusCode)
|
||||
}
|
||||
|
||||
select {
|
||||
case received := <-receivedData:
|
||||
if received.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl("", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty URL")
|
||||
}
|
||||
if err.Error() != "webhook URL is empty" {
|
||||
t.Errorf("Expected 'webhook URL is empty' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl("://invalid-url", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent host")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) {
|
||||
// Create a server that delays response beyond webhook timeout
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl(server.URL, data)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
}{
|
||||
{"200 OK", http.StatusOK},
|
||||
{"201 Created", http.StatusCreated},
|
||||
{"204 No Content", http.StatusNoContent},
|
||||
{"400 Bad Request", http.StatusBadRequest},
|
||||
{"404 Not Found", http.StatusNotFound},
|
||||
{"500 Internal Server Error", http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.statusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
|
||||
if err != nil {
|
||||
t.Errorf("sendWebhookToUrl failed: %v", err)
|
||||
}
|
||||
if statusCode != tc.statusCode {
|
||||
t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL, "", server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if requestCount != 2 {
|
||||
t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_WebhookDataStructure(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Status = base.DownloadStatusDone
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
// Verify event
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
|
||||
}
|
||||
// Verify time is set
|
||||
if data.Time == 0 {
|
||||
t.Error("Expected time to be set")
|
||||
}
|
||||
// Verify payload
|
||||
if data.Payload == nil {
|
||||
t.Error("Expected payload to be present")
|
||||
}
|
||||
if data.Payload.Task == nil {
|
||||
t.Error("Expected task in payload to be present")
|
||||
}
|
||||
// Verify task is cloned (has same ID but different pointer)
|
||||
if data.Payload.Task.ID != task.ID {
|
||||
t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Extra = make(map[string]any)
|
||||
cfg.Webhook.URLs = []string{}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
err := downloader.SendTestWebhook()
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for empty URLs, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {
|
||||
// First server returns 200
|
||||
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server1.Close()
|
||||
|
||||
// Second server returns 500
|
||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server2.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server1.URL, server2.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Should fail because server2 returns 500
|
||||
err := downloader.SendTestWebhook()
|
||||
if err == nil {
|
||||
t.Error("Expected error when one server returns non-200 status")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
err := downloader.TestWebhookUrl("://invalid")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
err := downloader.TestWebhookUrl(server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("TestWebhookUrl failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
// Verify it's a test webhook
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload with task")
|
||||
}
|
||||
// Verify test task properties
|
||||
task := data.Payload.Task
|
||||
if task.Protocol != "http" {
|
||||
t.Errorf("Expected protocol 'http', got '%s'", task.Protocol)
|
||||
}
|
||||
if task.Status != base.DownloadStatusDone {
|
||||
t.Errorf("Expected status Done, got %s", task.Status)
|
||||
}
|
||||
if task.Meta == nil || task.Meta.Req == nil {
|
||||
t.Error("Expected meta and request in test task")
|
||||
}
|
||||
if task.Meta.Req.URL != "https://example.com/test-file.zip" {
|
||||
t.Errorf("Expected test URL, got %s", task.Meta.Req.URL)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
}
|
||||
@@ -9,11 +9,28 @@ type ReqExtra struct {
|
||||
type OptsExtra struct {
|
||||
Connections int `json:"connections"`
|
||||
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
||||
AutoTorrent bool `json:"autoTorrent"`
|
||||
// nil means use global config, true/false means explicit setting
|
||||
AutoTorrent *bool `json:"autoTorrent"`
|
||||
// DeleteTorrentAfterDownload when true, deletes the .torrent file after creating BT task
|
||||
// nil means use global config, true/false means explicit setting
|
||||
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload"`
|
||||
// AutoExtract when task download complete, and it is an archive file, it will be auto extracted
|
||||
// nil means use global config, true/false means explicit setting
|
||||
AutoExtract *bool `json:"autoExtract"`
|
||||
// ArchivePassword is the password for extracting password-protected archives
|
||||
ArchivePassword string `json:"archivePassword"`
|
||||
// DeleteAfterExtract when true, deletes the archive file after successful extraction
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract"`
|
||||
}
|
||||
|
||||
// Stats for download
|
||||
type Stats struct {
|
||||
// http stats
|
||||
// health indicators of http
|
||||
Connections []*StatsConnection `json:"connections"`
|
||||
}
|
||||
|
||||
type StatsConnection struct {
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
Completed bool `json:"completed"`
|
||||
Failed bool `json:"failed"`
|
||||
RetryTimes int `json:"retryTimes"`
|
||||
}
|
||||
+46
-10
@@ -1,14 +1,15 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/gorilla/mux"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func Info(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -23,9 +24,9 @@ func Info(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
var req base.Request
|
||||
var req model.ResolveTask
|
||||
if ReadJson(r, w, &req) {
|
||||
rr, err := Downloader.Resolve(&req)
|
||||
rr, err := Downloader.Resolve(req.Req, req.Opts)
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
@@ -42,9 +43,9 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
err error
|
||||
)
|
||||
if req.Rid != "" {
|
||||
taskId, err = Downloader.Create(req.Rid, req.Opt)
|
||||
taskId, err = Downloader.Create(req.Rid)
|
||||
} else if req.Req != nil {
|
||||
taskId, err = Downloader.CreateDirect(req.Req, req.Opt)
|
||||
taskId, err = Downloader.CreateDirect(req.Req, req.Opts)
|
||||
} else {
|
||||
WriteJson(w, model.NewErrorResult("param invalid: rid or req", model.CodeInvalidParam))
|
||||
return
|
||||
@@ -58,13 +59,13 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateTaskBatch
|
||||
var req base.CreateTaskBatch
|
||||
if ReadJson(r, w, &req) {
|
||||
if len(req.Reqs) == 0 {
|
||||
WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam))
|
||||
return
|
||||
}
|
||||
taskIds, err := Downloader.CreateDirectBatch(req.Reqs, req.Opt)
|
||||
taskIds, err := Downloader.CreateDirectBatch(&req)
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
@@ -73,6 +74,28 @@ func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func PatchTask(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
taskId := vars["id"]
|
||||
if taskId == "" {
|
||||
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
|
||||
return
|
||||
}
|
||||
|
||||
var req model.ResolveTask
|
||||
if ReadJson(r, w, &req) {
|
||||
if err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil {
|
||||
if err == download.ErrTaskNotFound {
|
||||
WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound))
|
||||
return
|
||||
}
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
WriteJson(w, model.NewNilResult())
|
||||
}
|
||||
}
|
||||
|
||||
func PauseTask(w http.ResponseWriter, r *http.Request) {
|
||||
filter, errResult := parseIdFilter(r)
|
||||
if errResult != nil {
|
||||
@@ -309,6 +332,8 @@ func DoProxy(w http.ResponseWriter, r *http.Request) {
|
||||
r.RequestURI = ""
|
||||
r.URL = targetUrl
|
||||
r.Host = targetUrl.Host
|
||||
r.Header.Del("Authorization")
|
||||
r.Header.Del("X-Target-Uri")
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
writeError(w, err.Error())
|
||||
@@ -387,3 +412,14 @@ func getServerConfig() *base.DownloaderStoreConfig {
|
||||
cfg, _ := Downloader.GetConfig()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.TestWebhookReq
|
||||
if ReadJson(r, w, &req) {
|
||||
if err := Downloader.TestWebhookUrl(req.URL); err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
WriteJson(w, model.NewNilResult())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"io/fs"
|
||||
)
|
||||
@@ -25,9 +24,9 @@ type StartConfig struct {
|
||||
|
||||
ProductionMode bool
|
||||
|
||||
WebEnable bool
|
||||
WebFS fs.FS
|
||||
WebBasicAuth *WebBasicAuth
|
||||
WebEnable bool
|
||||
WebFS fs.FS
|
||||
WebAuth *WebAuth
|
||||
}
|
||||
|
||||
func (cfg *StartConfig) Init() *StartConfig {
|
||||
@@ -49,13 +48,7 @@ func (cfg *StartConfig) Init() *StartConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
type WebBasicAuth struct {
|
||||
type WebAuth struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Authorization returns the value of the Authorization header to be used in HTTP requests.
|
||||
func (cfg *WebBasicAuth) Authorization() string {
|
||||
userId := cfg.Username + ":" + cfg.Password
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId))
|
||||
}
|
||||
@@ -2,13 +2,14 @@ package model
|
||||
|
||||
import "github.com/GopeedLab/gopeed/pkg/base"
|
||||
|
||||
type CreateTask struct {
|
||||
Rid string `json:"rid"`
|
||||
Req *base.Request `json:"req"`
|
||||
Opt *base.Options `json:"opt"`
|
||||
type ResolveTask struct {
|
||||
Req *base.Request `json:"req"`
|
||||
Opts *base.Options `json:"opts"`
|
||||
}
|
||||
|
||||
type CreateTaskBatch struct {
|
||||
Reqs []*base.Request `json:"reqs"`
|
||||
Opt *base.Options `json:"opt"`
|
||||
type CreateTask struct {
|
||||
Rid string `json:"rid"`
|
||||
|
||||
Req *base.Request `json:"req"`
|
||||
Opts *base.Options `json:"opts"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
// TestWebhookReq is the request body for testing a single webhook URL
|
||||
type TestWebhookReq struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
+138
-18
@@ -2,27 +2,35 @@ package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
srv *http.Server
|
||||
runningPort int
|
||||
aesKey []byte
|
||||
|
||||
Downloader *download.Downloader
|
||||
)
|
||||
@@ -58,7 +66,9 @@ func Stop() {
|
||||
}()
|
||||
|
||||
if srv != nil {
|
||||
if err := srv.Shutdown(context.TODO()); err != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
Downloader.Logger.Warn().Err(err).Msg("shutdown server failed")
|
||||
}
|
||||
}
|
||||
@@ -76,8 +86,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
startCfg.Init()
|
||||
|
||||
downloadCfg := &download.DownloaderConfig{
|
||||
ProductionMode: startCfg.ProductionMode,
|
||||
RefreshInterval: startCfg.RefreshInterval,
|
||||
ProductionMode: startCfg.ProductionMode,
|
||||
RefreshInterval: startCfg.RefreshInterval,
|
||||
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
|
||||
}
|
||||
if startCfg.Storage == model.StorageBolt {
|
||||
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
|
||||
@@ -95,6 +106,13 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
util.SafeRemove(startCfg.Address)
|
||||
}
|
||||
|
||||
if startCfg.WebEnable {
|
||||
aesKey = make([]byte, 32)
|
||||
if _, err := rand.Read(aesKey); err != nil {
|
||||
return nil, nil, errors.Wrap(err, "generate aes key failed")
|
||||
}
|
||||
}
|
||||
|
||||
listener, err := net.Listen(startCfg.Network, startCfg.Address)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -105,6 +123,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch)
|
||||
r.Methods(http.MethodPatch).Path("/api/v1/tasks/{id}").HandlerFunc(PatchTask)
|
||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask)
|
||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks)
|
||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask)
|
||||
@@ -124,16 +143,42 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
|
||||
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
|
||||
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
|
||||
|
||||
enableApiToken := startCfg.ApiToken != ""
|
||||
enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
||||
if startCfg.WebEnable {
|
||||
if enableWebAuth {
|
||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var loginReq model.WebAuth
|
||||
if ReadJson(r, w, &loginReq) {
|
||||
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
|
||||
// Generate a login token, Username:Password:Timestamp
|
||||
timestamp := time.Now().Unix()
|
||||
tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp)
|
||||
token, err := aesEncrypt(aesKey, []byte(tokenData))
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
WriteJson(w, model.NewOkResult(token))
|
||||
return
|
||||
}
|
||||
}
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
})
|
||||
}
|
||||
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
|
||||
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
||||
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
|
||||
}
|
||||
if enableApiToken || enableWebAuth {
|
||||
writeUnauthorized := func(w http.ResponseWriter, r *http.Request) {
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
}
|
||||
|
||||
enableApiToken := startCfg.ApiToken != ""
|
||||
enableBasicAuth := startCfg.WebEnable && startCfg.WebBasicAuth != nil
|
||||
if enableApiToken || enableBasicAuth {
|
||||
r.Use(func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if enableApiToken {
|
||||
@@ -145,19 +190,49 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
return
|
||||
}
|
||||
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if startCfg.WebEnable && startCfg.WebBasicAuth != nil {
|
||||
if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() {
|
||||
if enableWebAuth {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"")
|
||||
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
tokenData, err := aesDecrypt(aesKey, token)
|
||||
if err != nil {
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(string(tokenData), ":", 3)
|
||||
username := parts[0]
|
||||
password := parts[1]
|
||||
timestamp, _ := strconv.Atoi(parts[2])
|
||||
|
||||
if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password {
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is expired (7 days)
|
||||
if time.Now().Unix()-int64(timestamp) > 7*24*3600 {
|
||||
writeUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
writeUnauthorized(w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -177,9 +252,10 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
})
|
||||
|
||||
srv = &http.Server{Handler: handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}),
|
||||
handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.AllowCredentials(),
|
||||
)(r)}
|
||||
return srv, listener, nil
|
||||
}
|
||||
@@ -315,3 +391,47 @@ func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func aesEncrypt(key, data []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cipherText := gcm.Seal(nonce, nonce, data, nil)
|
||||
return base64.StdEncoding.EncodeToString(cipherText), nil
|
||||
}
|
||||
|
||||
func aesDecrypt(key []byte, encryptedData string) ([]byte, error) {
|
||||
cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(cipherText) < gcm.NonceSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():]
|
||||
return gcm.Open(nil, nonce, cipherText, nil)
|
||||
}
|
||||
+269
-36
@@ -6,19 +6,21 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,13 +53,13 @@ var (
|
||||
"connections": 2,
|
||||
},
|
||||
}
|
||||
createReq = &model.CreateTask{
|
||||
Req: taskReq,
|
||||
Opt: createOpts,
|
||||
resolveReq = &model.ResolveTask{
|
||||
Req: taskReq,
|
||||
Opts: createOpts,
|
||||
}
|
||||
createResoledReq = &model.CreateTask{
|
||||
Req: taskReq,
|
||||
Opt: createOpts,
|
||||
createReq = &model.CreateTask{
|
||||
Req: taskReq,
|
||||
Opts: createOpts,
|
||||
}
|
||||
installExtensionReq = &model.InstallExtension{
|
||||
URL: "https://github.com/GopeedLab/gopeed-extension-samples#github-contributor-avatars-sample",
|
||||
@@ -78,7 +80,7 @@ func TestInfo(t *testing.T) {
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
doTest(func() {
|
||||
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", taskReq)
|
||||
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq)
|
||||
if !test.AssertResourceEqual(taskRes, resp.Res) {
|
||||
t.Errorf("Resolve() got = %v, want %v", test.ToJson(resp.Res), test.ToJson(taskRes))
|
||||
}
|
||||
@@ -87,7 +89,7 @@ func TestResolve(t *testing.T) {
|
||||
|
||||
func TestCreateTask(t *testing.T) {
|
||||
doTest(func() {
|
||||
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", taskReq)
|
||||
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
@@ -99,7 +101,6 @@ func TestCreateTask(t *testing.T) {
|
||||
|
||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", &model.CreateTask{
|
||||
Rid: resp.ID,
|
||||
Opt: createOpts,
|
||||
})
|
||||
if taskId == "" {
|
||||
t.Fatal("create task failed")
|
||||
@@ -140,11 +141,13 @@ func TestCreateDirectTask(t *testing.T) {
|
||||
|
||||
func TestCreateDirectTaskBatch(t *testing.T) {
|
||||
doTest(func() {
|
||||
reqs := make([]*base.Request, 0)
|
||||
reqs := make([]*base.CreateTaskBatchItem, 0)
|
||||
for i := 0; i < 5; i++ {
|
||||
reqs = append(reqs, createReq.Req)
|
||||
reqs = append(reqs, &base.CreateTaskBatchItem{
|
||||
Req: createReq.Req,
|
||||
})
|
||||
}
|
||||
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &model.CreateTaskBatch{
|
||||
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
Reqs: reqs,
|
||||
})
|
||||
if len(taskIds) != len(reqs) {
|
||||
@@ -153,26 +156,41 @@ func TestCreateDirectTaskBatch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateDirectTaskWithResoled(t *testing.T) {
|
||||
func TestCreateDirectTaskBatchWithOpt(t *testing.T) {
|
||||
doTest(func() {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
Downloader.Listener(func(event *download.Event) {
|
||||
if event.Key == download.EventKeyFinally {
|
||||
wg.Done()
|
||||
reqs := make([]*base.CreateTaskBatchItem, 0)
|
||||
for i := 0; i < 5; i++ {
|
||||
item := &base.CreateTaskBatchItem{
|
||||
Req: createReq.Req,
|
||||
}
|
||||
if i == 0 {
|
||||
item.Opts = &base.Options{
|
||||
Name: "spe_opt.data",
|
||||
}
|
||||
}
|
||||
reqs = append(reqs, item)
|
||||
}
|
||||
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
Reqs: reqs,
|
||||
Opts: &base.Options{
|
||||
Name: "default_opt.data",
|
||||
},
|
||||
})
|
||||
|
||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createResoledReq)
|
||||
if taskId == "" {
|
||||
t.Fatal("create task failed")
|
||||
if len(taskIds) != len(reqs) {
|
||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", len(taskIds), len(reqs))
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
want := test.FileMd5(test.BuildFile)
|
||||
got := test.FileMd5(test.DownloadFile)
|
||||
if want != got {
|
||||
t.Errorf("CreateDirectTaskWithResoled() got = %v, want %v", got, want)
|
||||
for i, taskId := range taskIds {
|
||||
task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
||||
if i == 0 {
|
||||
if !strings.Contains(task.Name(), "spe_opt") {
|
||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", task.Name(), "spe_opt.data")
|
||||
}
|
||||
} else {
|
||||
if !strings.Contains(task.Name(), "default_opt") {
|
||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", task.Name(), "default_opt.data")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -198,6 +216,7 @@ func TestPauseAndContinueTask(t *testing.T) {
|
||||
if t2.Status != base.DownloadStatusPause {
|
||||
t.Errorf("PauseTask() got = %v, want %v", t2.Status, base.DownloadStatusPause)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/continue", nil)
|
||||
t3 := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
||||
if t3.Status != base.DownloadStatusRunning {
|
||||
@@ -213,6 +232,60 @@ func TestPauseAndContinueTask(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchTask(t *testing.T) {
|
||||
doTest(func() {
|
||||
// Create a task
|
||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
|
||||
if taskId == "" {
|
||||
t.Fatal("create task failed")
|
||||
}
|
||||
|
||||
// Pause the task
|
||||
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil)
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// Patch the task with new labels
|
||||
patchReq := &model.ResolveTask{
|
||||
Req: &base.Request{
|
||||
Labels: map[string]string{
|
||||
"patched": "true",
|
||||
"version": "2",
|
||||
},
|
||||
},
|
||||
}
|
||||
httpRequestCheckOk[any](http.MethodPatch, "/api/v1/tasks/"+taskId, patchReq)
|
||||
|
||||
// Verify the patch was applied
|
||||
task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
||||
if task.Meta.Req.Labels["patched"] != "true" {
|
||||
t.Errorf("PatchTask() label 'patched' = %v, want %v", task.Meta.Req.Labels["patched"], "true")
|
||||
}
|
||||
if task.Meta.Req.Labels["version"] != "2" {
|
||||
t.Errorf("PatchTask() label 'version' = %v, want %v", task.Meta.Req.Labels["version"], "2")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchTaskNotFound(t *testing.T) {
|
||||
doTest(func() {
|
||||
// Try to patch a non-existent task
|
||||
patchReq := &model.ResolveTask{
|
||||
Req: &base.Request{
|
||||
Labels: map[string]string{
|
||||
"test": "value",
|
||||
},
|
||||
},
|
||||
}
|
||||
code, _ := httpRequest[any](http.MethodPatch, "/api/v1/tasks/non-existent-id", patchReq)
|
||||
if code != int(model.CodeTaskNotFound) {
|
||||
t.Errorf("PatchTaskNotFound() result code = %v, want %v", code, model.CodeTaskNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||
doTest(func() {
|
||||
cfg, err := Downloader.GetConfig()
|
||||
@@ -222,6 +295,7 @@ func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||
|
||||
createAndPause := func() {
|
||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
httpRequestCheckOk[*download.Task](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil)
|
||||
}
|
||||
|
||||
@@ -229,6 +303,7 @@ func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||
for i := 0; i < total; i++ {
|
||||
createAndPause()
|
||||
}
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
|
||||
// continue all
|
||||
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil)
|
||||
@@ -564,6 +639,116 @@ func TestDoProxy(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestWebhook(t *testing.T) {
|
||||
doTest(func() {
|
||||
// Set up a mock webhook server
|
||||
webhookReceived := false
|
||||
var receivedData map[string]interface{}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
webhookServer := http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &receivedData); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check Content-Type
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json")
|
||||
}
|
||||
|
||||
webhookReceived = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
wg.Done()
|
||||
}),
|
||||
}
|
||||
|
||||
// Start webhook server
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webhookPort := listener.Addr().(*net.TCPAddr).Port
|
||||
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort)
|
||||
|
||||
go webhookServer.Serve(listener)
|
||||
defer webhookServer.Close()
|
||||
|
||||
// Test with valid webhook URL
|
||||
httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: webhookURL,
|
||||
})
|
||||
|
||||
// Wait for webhook to be received
|
||||
wg.Wait()
|
||||
|
||||
if !webhookReceived {
|
||||
t.Error("TestWebhook() webhook was not received")
|
||||
}
|
||||
|
||||
// Verify webhook data structure
|
||||
if receivedData["event"] == nil {
|
||||
t.Error("TestWebhook() missing 'event' field")
|
||||
}
|
||||
if receivedData["time"] == nil {
|
||||
t.Error("TestWebhook() missing 'time' field")
|
||||
}
|
||||
if receivedData["payload"] == nil {
|
||||
t.Error("TestWebhook() missing 'payload' field")
|
||||
}
|
||||
|
||||
// Test with invalid webhook URL
|
||||
code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook",
|
||||
})
|
||||
checkCode(code, model.CodeError)
|
||||
|
||||
// Test with empty URL
|
||||
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: "",
|
||||
})
|
||||
checkCode(code, model.CodeError)
|
||||
|
||||
// Test with webhook server returning non-200 status
|
||||
wg.Add(1)
|
||||
badWebhookServer := http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
wg.Done()
|
||||
}),
|
||||
}
|
||||
badListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badWebhookPort := badListener.Addr().(*net.TCPAddr).Port
|
||||
badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort)
|
||||
|
||||
go badWebhookServer.Serve(badListener)
|
||||
defer badWebhookServer.Close()
|
||||
|
||||
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: badWebhookURL,
|
||||
})
|
||||
checkCode(code, model.CodeError)
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiToken(t *testing.T) {
|
||||
var cfg = &model.StartConfig{}
|
||||
cfg.Init()
|
||||
@@ -595,7 +780,7 @@ func TestAuthorization(t *testing.T) {
|
||||
cfg.Init()
|
||||
cfg.ApiToken = "123456"
|
||||
cfg.WebEnable = true
|
||||
cfg.WebBasicAuth = &model.WebBasicAuth{
|
||||
cfg.WebAuth = &model.WebAuth{
|
||||
Username: "admin",
|
||||
Password: "123456",
|
||||
}
|
||||
@@ -607,14 +792,61 @@ func TestAuthorization(t *testing.T) {
|
||||
Stop()
|
||||
}()
|
||||
|
||||
status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
|
||||
status, _ := doHttpRequest0(http.MethodPost, "/api/web/login", nil, &model.WebAuth{
|
||||
Username: "xxx",
|
||||
Password: "xxx",
|
||||
})
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
token := httpRequestCheckOk[string](http.MethodPost, "/api/web/login", cfg.WebAuth)
|
||||
authToken := fmt.Sprintf("Bearer %s", token)
|
||||
authHeaders := map[string]string{
|
||||
"Authorization": authToken,
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": cfg.WebBasicAuth.Authorization(),
|
||||
"Authorization": "xxx",
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": "xxx",
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
buildToken := func(username, password string, ts int64) string {
|
||||
token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts)))
|
||||
return token
|
||||
}
|
||||
|
||||
fakeToken := buildToken("fake", "fake", time.Now().Unix())
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", fakeToken),
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix())
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", expireToken),
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authHeaders, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
@@ -627,7 +859,7 @@ func TestAuthorization(t *testing.T) {
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": cfg.WebBasicAuth.Authorization(),
|
||||
"Authorization": authToken,
|
||||
"X-Api-Token": cfg.ApiToken,
|
||||
}, nil)
|
||||
if status != http.StatusOK {
|
||||
@@ -635,7 +867,7 @@ func TestAuthorization(t *testing.T) {
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": cfg.WebBasicAuth.Authorization(),
|
||||
"Authorization": authToken,
|
||||
"X-Api-Token": "",
|
||||
}, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
@@ -666,6 +898,7 @@ func doTest0(onStart func(cfg *model.StartConfig), handler func()) {
|
||||
Downloader.Clear()
|
||||
}()
|
||||
defer func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
Downloader.Pause(nil)
|
||||
Downloader.Delete(nil, true)
|
||||
os.RemoveAll(cfg.StorageDir)
|
||||
|
||||
+14
-1
@@ -5,14 +5,27 @@ import (
|
||||
"math"
|
||||
)
|
||||
|
||||
const unknownSize = "unknown"
|
||||
|
||||
var unitArr = []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
|
||||
|
||||
func ByteFmt(size int64) string {
|
||||
if size == 0 {
|
||||
return "unknown"
|
||||
return unknownSize
|
||||
}
|
||||
// Handle negative values
|
||||
if size < 0 {
|
||||
return unknownSize
|
||||
}
|
||||
fs := float64(size)
|
||||
p := int(math.Log(fs) / math.Log(1024))
|
||||
// Ensure index is within bounds
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
if p >= len(unitArr) {
|
||||
p = len(unitArr) - 1
|
||||
}
|
||||
val := fs / math.Pow(1024, float64(p))
|
||||
_, frac := math.Modf(val)
|
||||
if frac > 0 {
|
||||
|
||||
@@ -14,7 +14,17 @@ func TestByteFmt(t *testing.T) {
|
||||
{
|
||||
name: "unknown",
|
||||
args: args{size: int64(0)},
|
||||
want: "unknown",
|
||||
want: unknownSize,
|
||||
},
|
||||
{
|
||||
name: "negative value",
|
||||
args: args{size: int64(-1)},
|
||||
want: unknownSize,
|
||||
},
|
||||
{
|
||||
name: "negative min int64",
|
||||
args: args{size: int64(-9223372036854775808)},
|
||||
want: unknownSize,
|
||||
},
|
||||
{
|
||||
name: "100B",
|
||||
@@ -51,6 +61,11 @@ func TestByteFmt(t *testing.T) {
|
||||
args: args{size: int64(1024 * 1024 * 2)},
|
||||
want: "2MB",
|
||||
},
|
||||
{
|
||||
name: "large value",
|
||||
args: args{size: int64(9223372036854775807)}, // max int64
|
||||
want: "8EB",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -26,3 +26,13 @@ func DeepClone[T any](v *T) *T {
|
||||
json.Unmarshal(b, &t)
|
||||
return &t
|
||||
}
|
||||
|
||||
// Ptr returns a pointer to the given value.
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
// BoolPtr returns a pointer to a bool value.
|
||||
func BoolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
+153
-14
@@ -8,6 +8,8 @@ import (
|
||||
syspath "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func Dir(path string) string {
|
||||
@@ -50,12 +52,17 @@ func CheckDuplicateAndRename(path string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
index := strings.LastIndex(name, ".")
|
||||
ext := syspath.Ext(name)
|
||||
var nameTpl string
|
||||
if index == -1 {
|
||||
// Special case: if the extension is the entire filename (like .gitignore),
|
||||
// or if index of last dot is 0 (starts with dot), treat it as no extension
|
||||
if ext == "" || ext == name || (len(ext) > 0 && strings.LastIndex(name, ".") == 0) {
|
||||
// No extension or hidden file without extension
|
||||
nameTpl = name + " (%d)"
|
||||
} else {
|
||||
nameTpl = name[:index] + " (%d)" + name[index:]
|
||||
// Has extension
|
||||
nameWithoutExt := name[:len(name)-len(ext)]
|
||||
nameTpl = nameWithoutExt + " (%d)" + ext
|
||||
}
|
||||
for i := 1; ; i++ {
|
||||
newName := fmt.Sprintf(nameTpl, i)
|
||||
@@ -135,17 +142,6 @@ func CopyDir(source string, target string, excludeDir ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RmAndMkDirAll remove and create directory, if the directory already exists, it will be overwritten.
|
||||
func RmAndMkDirAll(path string) error {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copy file, if the target file already exists, it will be overwritten.
|
||||
func copyForce(source string, target string) error {
|
||||
sourceFile, err := os.Open(source)
|
||||
@@ -184,7 +180,65 @@ func IsExistsFile(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
// MaxFilenameLength is the maximum length in bytes for a filename
|
||||
MaxFilenameLength = 100
|
||||
// maxExtensionLength is the maximum length in bytes for a file extension
|
||||
// to be treated as a valid extension. Extensions longer than this are
|
||||
// treated as part of the filename to avoid edge cases.
|
||||
maxExtensionLength = 20
|
||||
)
|
||||
|
||||
// SafeFilename sanitizes a filename by replacing invalid characters and truncating to a safe length.
|
||||
// It performs two operations:
|
||||
// 1. Replaces invalid path characters (platform-specific) with underscores
|
||||
// 2. Truncates filename to MaxFilenameLength bytes while preserving the file extension
|
||||
// The function handles UTF-8 multi-byte characters correctly by truncating at valid boundaries.
|
||||
func SafeFilename(filename string) string {
|
||||
if filename == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Step 1: Replace invalid characters
|
||||
for _, char := range invalidPathChars {
|
||||
filename = strings.ReplaceAll(filename, char, "_")
|
||||
}
|
||||
|
||||
// Step 2: Truncate if needed
|
||||
if len(filename) <= MaxFilenameLength {
|
||||
return filename
|
||||
}
|
||||
|
||||
// Find the extension (last dot in filename)
|
||||
ext := ""
|
||||
lastDot := strings.LastIndex(filename, ".")
|
||||
|
||||
// Only treat as extension if:
|
||||
// 1. There is a dot
|
||||
// 2. The dot is not at the start (not a hidden file like .gitignore)
|
||||
// 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases
|
||||
if lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength {
|
||||
ext = filename[lastDot:]
|
||||
filename = filename[:lastDot]
|
||||
}
|
||||
|
||||
// Calculate how much space we have for the base name
|
||||
availableLength := MaxFilenameLength - len(ext)
|
||||
|
||||
// Ensure we have at least some space for the base name
|
||||
if availableLength < 1 {
|
||||
// Extension itself is too long or no room, just truncate everything at byte boundary
|
||||
return truncateAtValidUTF8Boundary(filename+ext, MaxFilenameLength)
|
||||
}
|
||||
|
||||
// Truncate the base name at a valid UTF-8 boundary
|
||||
truncatedBase := truncateAtValidUTF8Boundary(filename, availableLength)
|
||||
|
||||
return truncatedBase + ext
|
||||
}
|
||||
|
||||
// ReplaceInvalidFilename replace invalid path characters
|
||||
// Deprecated: Use SafeFilename instead which also handles length truncation
|
||||
func ReplaceInvalidFilename(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
@@ -194,3 +248,88 @@ func ReplaceInvalidFilename(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TruncateFilename truncates a filename to a maximum byte length while preserving the extension.
|
||||
// Deprecated: Use SafeFilename instead which also handles invalid character replacement
|
||||
func TruncateFilename(filename string, maxLength int) string {
|
||||
// If already short enough, return as-is
|
||||
if len(filename) <= maxLength {
|
||||
return filename
|
||||
}
|
||||
|
||||
// Find the extension (last dot in filename)
|
||||
ext := ""
|
||||
lastDot := strings.LastIndex(filename, ".")
|
||||
|
||||
// Only treat as extension if:
|
||||
// 1. There is a dot
|
||||
// 2. The dot is not at the start (not a hidden file like .gitignore)
|
||||
// 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases
|
||||
if lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength {
|
||||
ext = filename[lastDot:]
|
||||
filename = filename[:lastDot]
|
||||
}
|
||||
|
||||
// Calculate how much space we have for the base name
|
||||
availableLength := maxLength - len(ext)
|
||||
|
||||
// Ensure we have at least some space for the base name
|
||||
if availableLength < 1 {
|
||||
// Extension itself is too long or no room, just truncate everything at byte boundary
|
||||
return truncateAtValidUTF8Boundary(filename+ext, maxLength)
|
||||
}
|
||||
|
||||
// Truncate the base name at a valid UTF-8 boundary
|
||||
truncatedBase := truncateAtValidUTF8Boundary(filename, availableLength)
|
||||
|
||||
return truncatedBase + ext
|
||||
}
|
||||
|
||||
// truncateAtValidUTF8Boundary truncates a string to at most maxBytes,
|
||||
// ensuring we don't cut in the middle of a UTF-8 character
|
||||
func truncateAtValidUTF8Boundary(s string, maxBytes int) string {
|
||||
if len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
|
||||
// Truncate at byte position
|
||||
truncated := s[:maxBytes]
|
||||
|
||||
// Find the last valid UTF-8 character boundary
|
||||
// Walk backwards to find where the last complete character ends
|
||||
for len(truncated) > 0 {
|
||||
// Check if this is a valid UTF-8 string
|
||||
if utf8.ValidString(truncated) {
|
||||
return truncated
|
||||
}
|
||||
// Remove one byte and try again
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
|
||||
return truncated
|
||||
}
|
||||
|
||||
// ReplacePathPlaceholders replaces date placeholders in a path with actual values
|
||||
// Supported placeholders:
|
||||
// - %year% - Current year (e.g., 2025)
|
||||
// - %month% - Current month (01-12)
|
||||
// - %day% - Current day (01-31)
|
||||
// - %date% - Full date format (2025-01-01)
|
||||
func ReplacePathPlaceholders(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
year := fmt.Sprintf("%d", now.Year())
|
||||
month := fmt.Sprintf("%02d", now.Month())
|
||||
day := fmt.Sprintf("%02d", now.Day())
|
||||
date := fmt.Sprintf("%s-%s-%s", year, month, day)
|
||||
|
||||
path = strings.ReplaceAll(path, "%year%", year)
|
||||
path = strings.ReplaceAll(path, "%month%", month)
|
||||
path = strings.ReplaceAll(path, "%day%", day)
|
||||
path = strings.ReplaceAll(path, "%date%", date)
|
||||
|
||||
return path
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDir(t *testing.T) {
|
||||
@@ -106,13 +109,28 @@ func TestSafeRemove(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckDuplicateAndRename(t *testing.T) {
|
||||
// Test with extension
|
||||
doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
|
||||
doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
|
||||
doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
|
||||
|
||||
// Test without extension
|
||||
doCheckDuplicateAndRename(t, []string{}, "a", "a")
|
||||
doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
|
||||
doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
|
||||
|
||||
// Test hidden files (starting with dot)
|
||||
doCheckDuplicateAndRename(t, []string{}, ".gitignore", ".gitignore")
|
||||
doCheckDuplicateAndRename(t, []string{".gitignore"}, ".gitignore", ".gitignore (1)")
|
||||
doCheckDuplicateAndRename(t, []string{".gitignore", ".gitignore (1)"}, ".gitignore", ".gitignore (2)")
|
||||
|
||||
// Test hidden files with extension
|
||||
doCheckDuplicateAndRename(t, []string{}, ".config.json", ".config.json")
|
||||
doCheckDuplicateAndRename(t, []string{".config.json"}, ".config.json", ".config (1).json")
|
||||
|
||||
// Test multiple dots
|
||||
doCheckDuplicateAndRename(t, []string{}, "test.tar.gz", "test.tar.gz")
|
||||
doCheckDuplicateAndRename(t, []string{"test.tar.gz"}, "test.tar.gz", "test.tar (1).gz")
|
||||
}
|
||||
|
||||
func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {
|
||||
@@ -225,3 +243,254 @@ func TestReplaceInvalidFilename(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeFilename tests the combined filename sanitization functionality
|
||||
func TestSafeFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "short filename - no changes needed",
|
||||
filename: "test.txt",
|
||||
want: "test.txt",
|
||||
},
|
||||
{
|
||||
name: "empty filename",
|
||||
filename: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "invalid chars only",
|
||||
filename: "te/st:file.txt",
|
||||
want: "te_st_file.txt",
|
||||
},
|
||||
{
|
||||
name: "long filename only",
|
||||
filename: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt",
|
||||
want: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt",
|
||||
},
|
||||
{
|
||||
name: "both invalid chars and too long",
|
||||
filename: "path/to/very:long*filename?that<exceeds>filesystem|limits_and_has_invalid_characters_everywhere.pdf",
|
||||
want: "path_to_very_long*filename?that<exceeds>filesystem|limits_and_has_invalid_characters_everywhere.pdf",
|
||||
},
|
||||
{
|
||||
name: "unicode with invalid chars and truncation",
|
||||
filename: "测试/文件名:非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf",
|
||||
want: "测试_文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文.pdf",
|
||||
},
|
||||
{
|
||||
name: "hidden file with truncation",
|
||||
filename: ".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_length",
|
||||
want: ".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_lengt",
|
||||
},
|
||||
{
|
||||
name: "multiple dots and invalid chars",
|
||||
filename: "archive/tar.gz.backup:old.txt",
|
||||
want: "archive_tar.gz.backup_old.txt",
|
||||
},
|
||||
{
|
||||
name: "extension longer than reasonable",
|
||||
filename: "test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_length",
|
||||
want: "test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_lengt",
|
||||
},
|
||||
{
|
||||
name: "filename with spaces and invalid chars",
|
||||
filename: "my document/with:spaces and a very long name that needs to be truncated because it exceeds the maximum length.docx",
|
||||
want: "my document_with_spaces and a very long name that needs to be truncated because it exceeds the .docx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SafeFilename(tt.filename)
|
||||
if got != tt.want {
|
||||
t.Errorf("SafeFilename() = %q (len=%d), want %q (len=%d)",
|
||||
got, len(got), tt.want, len(tt.want))
|
||||
}
|
||||
// Verify result doesn't exceed MaxFilenameLength
|
||||
if len(got) > MaxFilenameLength {
|
||||
t.Errorf("SafeFilename() result length %d exceeds MaxFilenameLength %d",
|
||||
len(got), MaxFilenameLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePathPlaceholders(t *testing.T) {
|
||||
now := time.Now()
|
||||
year := fmt.Sprintf("%d", now.Year())
|
||||
month := fmt.Sprintf("%02d", now.Month())
|
||||
day := fmt.Sprintf("%02d", now.Day())
|
||||
date := fmt.Sprintf("%s-%s-%s", year, month, day)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty path",
|
||||
path: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no placeholders",
|
||||
path: "/home/user/Downloads",
|
||||
want: "/home/user/Downloads",
|
||||
},
|
||||
{
|
||||
name: "year placeholder",
|
||||
path: "/Downloads/%year%",
|
||||
want: "/Downloads/" + year,
|
||||
},
|
||||
{
|
||||
name: "month placeholder",
|
||||
path: "/Downloads/%month%",
|
||||
want: "/Downloads/" + month,
|
||||
},
|
||||
{
|
||||
name: "day placeholder",
|
||||
path: "/Downloads/%day%",
|
||||
want: "/Downloads/" + day,
|
||||
},
|
||||
{
|
||||
name: "date placeholder",
|
||||
path: "/Downloads/%date%",
|
||||
want: "/Downloads/" + date,
|
||||
},
|
||||
{
|
||||
name: "multiple placeholders",
|
||||
path: "/Downloads/%year%-%month%",
|
||||
want: "/Downloads/" + year + "-" + month,
|
||||
},
|
||||
{
|
||||
name: "mixed path with placeholders",
|
||||
path: "/home/user/Downloads/%year%/%month%/%day%",
|
||||
want: "/home/user/Downloads/" + year + "/" + month + "/" + day,
|
||||
},
|
||||
{
|
||||
name: "windows style path",
|
||||
path: "D:\\Downloads\\%year%-%month%",
|
||||
want: "D:\\Downloads\\" + year + "-" + month,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ReplacePathPlaceholders(tt.path)
|
||||
if !strings.Contains(got, year) && tt.path != "" && strings.Contains(tt.path, "%year%") {
|
||||
t.Errorf("ReplacePathPlaceholders() = %v, want containing year %v", got, year)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ReplacePathPlaceholders() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateFilename tests the filename truncation functionality
|
||||
func TestTruncateFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
maxLength int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "short filename - no truncation",
|
||||
filename: "test.txt",
|
||||
maxLength: 100,
|
||||
want: "test.txt",
|
||||
},
|
||||
{
|
||||
name: "filename at exact limit",
|
||||
filename: "abcdefghij.txt", // 14 chars
|
||||
maxLength: 14,
|
||||
want: "abcdefghij.txt",
|
||||
},
|
||||
{
|
||||
name: "long filename with extension - truncate base",
|
||||
filename: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt",
|
||||
maxLength: 100,
|
||||
want: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt",
|
||||
},
|
||||
{
|
||||
name: "long filename without extension",
|
||||
filename: "this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation",
|
||||
maxLength: 100,
|
||||
want: "this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation",
|
||||
},
|
||||
{
|
||||
name: "filename with multiple dots",
|
||||
filename: "archive.tar.gz.backup.old.file.with.many.dots.txt",
|
||||
maxLength: 30,
|
||||
want: "archive.tar.gz.backup.old..txt", // Preserves last extension
|
||||
},
|
||||
{
|
||||
name: "hidden file (starts with dot)",
|
||||
filename: ".gitignore_with_very_long_name_that_needs_truncation",
|
||||
maxLength: 30,
|
||||
want: ".gitignore_with_very_long_name",
|
||||
},
|
||||
{
|
||||
name: "only extension (no base name)",
|
||||
filename: ".txt",
|
||||
maxLength: 100,
|
||||
want: ".txt",
|
||||
},
|
||||
{
|
||||
name: "unicode characters in filename",
|
||||
filename: "测试文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf",
|
||||
maxLength: 50,
|
||||
want: "测试文件名_非常长的中文文件名_.pdf", // Truncated at byte boundary, preserving UTF-8
|
||||
},
|
||||
{
|
||||
name: "extension longer than reasonable (>20 chars)",
|
||||
filename: "test.verylongextensionthatshouldnotbetreatedasextension",
|
||||
maxLength: 30,
|
||||
want: "test.verylongextensionthatshou",
|
||||
},
|
||||
{
|
||||
name: "very short max length with extension",
|
||||
filename: "document.pdf",
|
||||
maxLength: 10,
|
||||
want: "docume.pdf",
|
||||
},
|
||||
{
|
||||
name: "maxLength smaller than extension",
|
||||
filename: "test.pdf",
|
||||
maxLength: 3,
|
||||
want: "tes",
|
||||
},
|
||||
{
|
||||
name: "empty filename",
|
||||
filename: "",
|
||||
maxLength: 100,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "filename with spaces",
|
||||
filename: "my document with spaces and a very long name that needs to be truncated.docx",
|
||||
maxLength: 50,
|
||||
want: "my document with spaces and a very long name .docx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := TruncateFilename(tt.filename, tt.maxLength)
|
||||
if got != tt.want {
|
||||
t.Errorf("TruncateFilename() = %q (len=%d), want %q (len=%d)",
|
||||
got, len(got), tt.want, len(tt.want))
|
||||
}
|
||||
// Verify result doesn't exceed maxLength
|
||||
if len(got) > tt.maxLength {
|
||||
t.Errorf("TruncateFilename() result length %d exceeds maxLength %d",
|
||||
len(got), tt.maxLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -56,3 +56,26 @@ func ProxyUrlToHandler(proxyUrl *url.URL) func(*http.Request) (*url.URL, error)
|
||||
}
|
||||
return http.ProxyURL(proxyUrl)
|
||||
}
|
||||
|
||||
// TryUrlQueryUnescape tries to unescape a URL-encoded string.
|
||||
//
|
||||
// If unescaping fails, it returns the original string.
|
||||
func TryUrlQueryUnescape(s string) string {
|
||||
if decoded, err := url.QueryUnescape(s); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TryUrlPathUnescape tries to unescape a URL path-encoded string.
|
||||
// Unlike QueryUnescape, PathUnescape does not treat '+' as a space.
|
||||
// This is the correct function to use for decoding URL paths and filenames
|
||||
// where %2B should decode to '+', not to a space.
|
||||
//
|
||||
// If unescaping fails, it returns the original string.
|
||||
func TryUrlPathUnescape(s string) string {
|
||||
if decoded, err := url.PathUnescape(s); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -128,3 +128,52 @@ func TestParseDataUri(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryURLDecode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"normal.txt", "normal.txt"},
|
||||
{"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"},
|
||||
{"hello%20world.txt", "hello world.txt"},
|
||||
{"bad%2-text", "bad%2-text"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := TryUrlQueryUnescape(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("TryUrlQueryUnescape(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryUrlPathUnescape(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"normal.txt", "normal.txt"},
|
||||
{"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"},
|
||||
{"hello%20world.txt", "hello world.txt"},
|
||||
{"bad%2-text", "bad%2-text"},
|
||||
// The key difference: %2B should decode to + (not space)
|
||||
{"C%2B%2B%20Primer.txt", "C++ Primer.txt"},
|
||||
{"test%2Bfile.txt", "test+file.txt"},
|
||||
// Plus sign in path should remain as-is (not decoded to space)
|
||||
{"test+plus.txt", "test+plus.txt"},
|
||||
// Mixed encoding
|
||||
{"C%2B%2B%20%20Primer%20%20Plus.mobi", "C++ Primer Plus.mobi"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := TryUrlPathUnescape(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("TryUrlPathUnescape(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'dev.flutter.flutter-gradle-plugin'
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
@@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) {
|
||||
}
|
||||
}
|
||||
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
@@ -21,21 +22,19 @@ if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
namespace 'com.gopeed.gopeed'
|
||||
compileSdk 35
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
@@ -44,21 +43,21 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.gopeed.gopeed"
|
||||
applicationId 'com.gopeed.gopeed'
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 34
|
||||
minSdk flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias "upload"
|
||||
keyPassword System.getenv("APK_KEY_PASSWORD")
|
||||
storeFile file("upload-keystore.jks")
|
||||
storePassword System.getenv("APK_STORE_PASSWORD")
|
||||
keyAlias 'upload'
|
||||
keyPassword System.getenv('APK_KEY_PASSWORD')
|
||||
storeFile file('upload-keystore.jks')
|
||||
storePassword System.getenv('APK_STORE_PASSWORD')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +83,6 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation(name: 'libgopeed', ext: 'aar')
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.gopeed.gopeed">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
@@ -1,12 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.gopeed.gopeed">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
@@ -69,6 +70,19 @@
|
||||
<data
|
||||
android:scheme="magnet" />
|
||||
</intent-filter>
|
||||
<!-- Handle http/https URLs with common downloadable mimeTypes -->
|
||||
<intent-filter android:label="Gopeed">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_DOWNLOADS" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
@@ -91,7 +105,7 @@
|
||||
</activity>
|
||||
|
||||
<!-- Add android:stopWithTask option only when necessary. -->
|
||||
<service
|
||||
<service
|
||||
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
@@ -102,4 +116,4 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -1,8 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.gopeed.gopeed">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
@@ -1,16 +1,3 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.8.22'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
@@ -26,6 +13,24 @@ subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
subprojects { subproject ->
|
||||
subproject.plugins.withId('com.android.library') {
|
||||
subproject.android {
|
||||
if (namespace == null || namespace.isEmpty()) {
|
||||
def manifest = file("${subproject.projectDir}/src/main/AndroidManifest.xml")
|
||||
if (manifest.exists()) {
|
||||
def manifestContent = manifest.getText()
|
||||
def packagePattern = /package\s*=\s*["']([^"']+)["']/
|
||||
def matcher = (manifestContent =~ packagePattern)
|
||||
if (matcher.find()) {
|
||||
namespace = matcher.group(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('clean', Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
@@ -1,11 +1,25 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file('local.properties').withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty('flutter.sdk')
|
||||
assert flutterSdkPath != null, 'flutter.sdk not set in local.properties'
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'dev.flutter.flutter-plugin-loader' version '1.0.0'
|
||||
id 'com.android.application' version '8.7.3' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
|
||||
}
|
||||
|
||||
include ':app'
|
||||
|
||||
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||
def properties = new Properties()
|
||||
|
||||
assert localPropertiesFile.exists()
|
||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||
+99
-37
@@ -3,19 +3,24 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:gopeed/api/model/extension.dart';
|
||||
import 'package:gopeed/api/model/install_extension.dart';
|
||||
import 'package:gopeed/api/model/switch_extension.dart';
|
||||
import 'package:get/get.dart' as getx;
|
||||
|
||||
import '../app/routes/app_pages.dart';
|
||||
import '../database/database.dart';
|
||||
import '../util/util.dart';
|
||||
import 'model/create_task.dart';
|
||||
import 'model/create_task_batch.dart';
|
||||
import 'model/downloader_config.dart';
|
||||
import 'model/request.dart';
|
||||
import 'model/extension.dart';
|
||||
import 'model/install_extension.dart';
|
||||
import 'model/login.dart';
|
||||
import 'model/resolve_result.dart';
|
||||
import 'model/resolve_task.dart';
|
||||
import 'model/result.dart';
|
||||
import 'model/switch_extension.dart';
|
||||
import 'model/task.dart';
|
||||
import 'model/update_extension_settings.dart';
|
||||
import 'model/update_check_extension_resp.dart';
|
||||
import 'model/update_extension_settings.dart';
|
||||
|
||||
class _Client {
|
||||
static _Client? _instance;
|
||||
@@ -29,12 +34,12 @@ class _Client {
|
||||
_instance = _Client._internal();
|
||||
var dio = Dio();
|
||||
final isUnixSocket = network == 'unix';
|
||||
var baseUrl = 'http://127.0.0.1';
|
||||
var baseUrl = 'http://127.0.0.1/';
|
||||
if (!isUnixSocket) {
|
||||
if (Util.isWeb()) {
|
||||
baseUrl = kDebugMode ? 'http://127.0.0.1:9999' : '';
|
||||
baseUrl = kDebugMode ? 'http://127.0.0.1:9999/' : '';
|
||||
} else {
|
||||
baseUrl = 'http://$address';
|
||||
baseUrl = 'http://$address/';
|
||||
}
|
||||
}
|
||||
dio.options.baseUrl = baseUrl;
|
||||
@@ -42,12 +47,27 @@ class _Client {
|
||||
dio.options.sendTimeout = const Duration(seconds: 5);
|
||||
dio.options.connectTimeout = const Duration(seconds: 5);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 60);
|
||||
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
|
||||
if (apiToken.isNotEmpty) {
|
||||
options.headers['X-Api-Token'] = apiToken;
|
||||
}
|
||||
handler.next(options);
|
||||
}));
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (apiToken.isNotEmpty) {
|
||||
options.headers['X-Api-Token'] = apiToken;
|
||||
}
|
||||
if (Util.isWeb()) {
|
||||
final token = Database.instance.getWebToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) {
|
||||
// Only web version has a login page
|
||||
if (Util.isWeb() && error.response?.statusCode == 401) {
|
||||
getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN);
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
));
|
||||
|
||||
_instance!.dio = dio;
|
||||
if (isUnixSocket) {
|
||||
@@ -95,25 +115,37 @@ Future<T> _parse<T>(
|
||||
} on DioException catch (e) {
|
||||
if (e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.connectionTimeout) {
|
||||
e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.connectionError) {
|
||||
throw TimeoutException("request timeout");
|
||||
}
|
||||
throw Exception(Result(code: 1000, msg: e.message));
|
||||
}
|
||||
}
|
||||
|
||||
Future<ResolveResult> resolve(Request request) async {
|
||||
Future<ResolveResult> resolve(ResolveTask resolveTask) async {
|
||||
return _parse<ResolveResult>(
|
||||
() => _client.dio.post("/api/v1/resolve", data: request),
|
||||
() => _client.dio.post("api/v1/resolve", data: resolveTask),
|
||||
(data) => ResolveResult.fromJson(data));
|
||||
}
|
||||
|
||||
Future<String> createTask(CreateTask createTask) async {
|
||||
return _parse<String>(
|
||||
() => _client.dio.post("/api/v1/tasks", data: createTask),
|
||||
() => _client.dio.post("api/v1/tasks", data: createTask),
|
||||
(data) => data as String);
|
||||
}
|
||||
|
||||
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) async {
|
||||
return _parse<List<String>>(
|
||||
() => _client.dio.post("api/v1/tasks/batch", data: createTaskBatch),
|
||||
(data) => (data as List).map((e) => e as String).toList());
|
||||
}
|
||||
|
||||
Future<void> patchTask(String id, ResolveTask patchTask) async {
|
||||
return _parse(
|
||||
() => _client.dio.patch("api/v1/tasks/$id", data: patchTask), null);
|
||||
}
|
||||
|
||||
Future<List<Task>> getTasks(List<Status> statuses) async {
|
||||
return _parse<List<Task>>(
|
||||
() => _client.dio.get(
|
||||
@@ -122,16 +154,16 @@ Future<List<Task>> getTasks(List<Status> statuses) async {
|
||||
}
|
||||
|
||||
Future<void> pauseTask(String id) async {
|
||||
return _parse(() => _client.dio.put("/api/v1/tasks/$id/pause"), null);
|
||||
return _parse(() => _client.dio.put("api/v1/tasks/$id/pause"), null);
|
||||
}
|
||||
|
||||
Future<void> continueTask(String id) async {
|
||||
return _parse(() => _client.dio.put("/api/v1/tasks/$id/continue"), null);
|
||||
return _parse(() => _client.dio.put("api/v1/tasks/$id/continue"), null);
|
||||
}
|
||||
|
||||
Future<void> pauseAllTasks(List<String>? ids) async {
|
||||
return _parse(
|
||||
() => _client.dio.put("/api/v1/tasks/pause", queryParameters: {
|
||||
() => _client.dio.put("api/v1/tasks/pause", queryParameters: {
|
||||
"id": ids,
|
||||
}),
|
||||
null);
|
||||
@@ -139,7 +171,7 @@ Future<void> pauseAllTasks(List<String>? ids) async {
|
||||
|
||||
Future<void> continueAllTasks(List<String>? ids) async {
|
||||
return _parse(
|
||||
() => _client.dio.put("/api/v1/tasks/continue", queryParameters: {
|
||||
() => _client.dio.put("api/v1/tasks/continue", queryParameters: {
|
||||
"id": ids,
|
||||
}),
|
||||
null);
|
||||
@@ -147,12 +179,12 @@ Future<void> continueAllTasks(List<String>? ids) async {
|
||||
|
||||
Future<void> deleteTask(String id, bool force) async {
|
||||
return _parse(
|
||||
() => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null);
|
||||
() => _client.dio.delete("api/v1/tasks/$id?force=$force"), null);
|
||||
}
|
||||
|
||||
Future<void> deleteTasks(List<String>? ids, bool force) async {
|
||||
return _parse(
|
||||
() => _client.dio.delete("/api/v1/tasks", queryParameters: {
|
||||
() => _client.dio.delete("api/v1/tasks", queryParameters: {
|
||||
"id": ids,
|
||||
"force": force,
|
||||
}),
|
||||
@@ -160,29 +192,29 @@ Future<void> deleteTasks(List<String>? ids, bool force) async {
|
||||
}
|
||||
|
||||
Future<DownloaderConfig> getConfig() async {
|
||||
return _parse(() => _client.dio.get("/api/v1/config"),
|
||||
return _parse(() => _client.dio.get("api/v1/config"),
|
||||
(data) => DownloaderConfig.fromJson(data));
|
||||
}
|
||||
|
||||
Future<void> putConfig(DownloaderConfig config) async {
|
||||
return _parse(() => _client.dio.put("/api/v1/config", data: config), null);
|
||||
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
|
||||
}
|
||||
|
||||
Future<void> installExtension(InstallExtension installExtension) async {
|
||||
return _parse(
|
||||
() => _client.dio.post("/api/v1/extensions", data: installExtension),
|
||||
null);
|
||||
Future<String> installExtension(InstallExtension installExtension) async {
|
||||
return _parse<String>(
|
||||
() => _client.dio.post("api/v1/extensions", data: installExtension),
|
||||
(data) => data as String);
|
||||
}
|
||||
|
||||
Future<List<Extension>> getExtensions() async {
|
||||
return _parse<List<Extension>>(() => _client.dio.get("/api/v1/extensions"),
|
||||
return _parse<List<Extension>>(() => _client.dio.get("api/v1/extensions"),
|
||||
(data) => (data as List).map((e) => Extension.fromJson(e)).toList());
|
||||
}
|
||||
|
||||
Future<void> updateExtensionSettings(
|
||||
String identity, UpdateExtensionSettings updateExtensionSettings) async {
|
||||
return _parse(
|
||||
() => _client.dio.put("/api/v1/extensions/$identity/settings",
|
||||
() => _client.dio.put("api/v1/extensions/$identity/settings",
|
||||
data: updateExtensionSettings),
|
||||
null);
|
||||
}
|
||||
@@ -191,22 +223,32 @@ Future<void> switchExtension(
|
||||
String identity, SwitchExtension switchExtension) async {
|
||||
return _parse(
|
||||
() => _client.dio
|
||||
.put("/api/v1/extensions/$identity/switch", data: switchExtension),
|
||||
.put("api/v1/extensions/$identity/switch", data: switchExtension),
|
||||
null);
|
||||
}
|
||||
|
||||
Future<void> deleteExtension(String identity) async {
|
||||
return _parse(() => _client.dio.delete("/api/v1/extensions/$identity"), null);
|
||||
return _parse(() => _client.dio.delete("api/v1/extensions/$identity"), null);
|
||||
}
|
||||
|
||||
Future<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) async {
|
||||
return _parse(() => _client.dio.get("/api/v1/extensions/$identity/update"),
|
||||
return _parse(() => _client.dio.get("api/v1/extensions/$identity/update"),
|
||||
(data) => UpdateCheckExtensionResp.fromJson(data));
|
||||
}
|
||||
|
||||
Future<void> updateExtension(String identity) async {
|
||||
return _parse(
|
||||
() => _client.dio.post("/api/v1/extensions/$identity/update"), null);
|
||||
() => _client.dio.post("api/v1/extensions/$identity/update"), null);
|
||||
}
|
||||
|
||||
Future<void> testWebhook(String url) async {
|
||||
return _parse(
|
||||
() => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null);
|
||||
}
|
||||
|
||||
Future<String> login(LoginReq loginReq) async {
|
||||
return _parse(() => _client.dio.post("api/web/login", data: loginReq),
|
||||
(data) => data as String);
|
||||
}
|
||||
|
||||
Future<Response<String>> proxyRequest<T>(String uri,
|
||||
@@ -223,5 +265,25 @@ Future<Response<String>> proxyRequest<T>(String uri,
|
||||
}
|
||||
|
||||
String join(String path) {
|
||||
return "${_client.dio.options.baseUrl}/${Util.cleanPath(path)}";
|
||||
final baseUrl = _client.dio.options.baseUrl;
|
||||
final cleanBaseUrl = baseUrl.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl;
|
||||
return "$cleanBaseUrl/${Util.cleanPath(path)}";
|
||||
}
|
||||
|
||||
/// Generic request method for API proxy
|
||||
/// Directly forwards requests to gopeed REST API
|
||||
Future<Response> forward(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
return _client.dio.request(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: Options(method: method),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api.dart';
|
||||
import 'model/store_extension.dart';
|
||||
|
||||
class GopeedSiteApi {
|
||||
GopeedSiteApi._();
|
||||
|
||||
static final instance = GopeedSiteApi._();
|
||||
|
||||
static const _host = 'gopeed.com';
|
||||
|
||||
Future<Map<String, dynamic>> getRelease() async {
|
||||
final json = await _getJson('/api/release');
|
||||
return json as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<StoreExtensionPage> getExtensions({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||
StoreSortOrder order = StoreSortOrder.desc,
|
||||
String? query,
|
||||
}) async {
|
||||
final json = await _getJson('/api/extensions', queryParameters: {
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||
});
|
||||
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> reportExtensionInstall(String id) async {
|
||||
final uri = Uri.https(_host, '/api/extensions/install');
|
||||
await proxyRequest(
|
||||
uri.toString(),
|
||||
data: {'id': id},
|
||||
options: Options(method: 'POST', contentType: Headers.jsonContentType),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path,
|
||||
{Map<String, String>? queryParameters}) async {
|
||||
final uri = Uri.https(_host, path, queryParameters);
|
||||
final Response<String> response = await proxyRequest(uri.toString());
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw Exception('Empty response from $uri');
|
||||
}
|
||||
return jsonDecode(response.data!);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,12 @@ part 'create_task.g.dart';
|
||||
class CreateTask {
|
||||
String? rid;
|
||||
Request? req;
|
||||
Options? opt;
|
||||
Options? opts;
|
||||
|
||||
CreateTask({
|
||||
this.rid,
|
||||
this.req,
|
||||
this.opt,
|
||||
this.opts,
|
||||
});
|
||||
|
||||
factory CreateTask.fromJson(
|
||||
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user