mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-12 19:33:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89bd3f3af0 | ||
|
|
89dfbe4c45 | ||
|
|
392ee36137 | ||
|
|
a34383a7e2 | ||
|
|
98879569f0 | ||
|
|
8ef3b63e8d | ||
|
|
d873cd411c | ||
|
|
9e9bcd4b01 | ||
|
|
5bd57608d2 | ||
|
|
ebfd113360 | ||
|
|
d69874e13e | ||
|
|
b0d45226d7 | ||
|
|
cd19772099 | ||
|
|
1e36599d52 | ||
|
|
fc0a1e0ef9 | ||
|
|
8719166dbf | ||
|
|
c0b70d319d | ||
|
|
e0951de270 | ||
|
|
be0fb05edd | ||
|
|
91edba9af6 | ||
|
|
7d423e2182 | ||
|
|
903c5c46c4 | ||
|
|
b8ff5ff97f | ||
|
|
c51c5b284d | ||
|
|
4d65948934 | ||
|
|
c275bc2e46 | ||
|
|
9aae13453a | ||
|
|
d125d4ab75 | ||
|
|
4214765d39 | ||
|
|
47cafb7575 | ||
|
|
de65149b29 | ||
|
|
5462b14705 | ||
|
|
eee39e012f | ||
|
|
12860bb207 | ||
|
|
b86c2c18e7 | ||
|
|
6f20790b17 | ||
|
|
fa622a4063 | ||
|
|
c6619794fc | ||
|
|
87a8f87b70 | ||
|
|
ba84a04a43 | ||
|
|
224b488087 | ||
|
|
7ce4fb7f0e | ||
|
|
8d1ae93b03 | ||
|
|
363153fa33 | ||
|
|
f0dab90e09 | ||
|
|
6e60889ce8 | ||
|
|
88d150b933 | ||
|
|
694b43e3f9 | ||
|
|
fe25ccab1d | ||
|
|
8301b1f9ce | ||
|
|
49aab4ea7c | ||
|
|
5d8267fa56 | ||
|
|
1ab452ced8 | ||
|
|
743f356567 | ||
|
|
b1dd46cc7c | ||
|
|
38750d8505 | ||
|
|
7c1447f659 | ||
|
|
21702d5d46 | ||
|
|
e99ef6c7f4 | ||
|
|
c667f097dc | ||
|
|
46c34f2e92 | ||
|
|
e47a20a1bd | ||
|
|
cee5f3336e | ||
|
|
a760290213 | ||
|
|
a29f4bb42a | ||
|
|
62e8ab3cef | ||
|
|
8fa9a1e902 | ||
|
|
46675f26af | ||
|
|
0d52b840a2 | ||
|
|
091e7b8936 | ||
|
|
ff8f61423e | ||
|
|
e5ac13cfdc | ||
|
|
073e1413ab | ||
|
|
04b9aa18ec | ||
|
|
08fe4eef4e | ||
|
|
8f9bac9e6b |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Keep upstream plugin sources recognizable and collapsed in GitHub reviews.
|
||||
ui/flutter/third_party/** linguist-vendored whitespace=-blank-at-eol,-blank-at-eof
|
||||
@@ -106,17 +106,32 @@ jobs:
|
||||
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
|
||||
# Use the same toolchain as cgo instead of Git's bundled DLLs.
|
||||
$cc = (Get-Command -Name (go env CC) -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
foreach ($dll in @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll")) {
|
||||
$runtimePath = [string](& $cc "-print-file-name=$dll")
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runtimePath)) {
|
||||
throw "Failed to locate $dll using $cc"
|
||||
}
|
||||
$runtimePath = $runtimePath.Trim()
|
||||
# Some MinGW distributions keep runtime DLLs beside gcc.exe.
|
||||
if ($runtimePath -eq $dll) {
|
||||
$runtimePath = Join-Path (Split-Path -Parent $cc) $dll
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
|
||||
throw "MinGW runtime $dll was not found by $cc (reported path: $runtimePath)"
|
||||
}
|
||||
Write-Host "Copying MinGW runtime: $runtimePath"
|
||||
Copy-Item -LiteralPath $runtimePath -Destination $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
|
||||
@@ -783,5 +798,5 @@ jobs:
|
||||
linux/arm64
|
||||
linux/arm/v7
|
||||
tags: |
|
||||
liwei2633/gopeed:latest
|
||||
${{ !contains(env.VERSION, 'beta') && 'liwei2633/gopeed:latest' || '' }}
|
||||
liwei2633/gopeed:v${{ env.VERSION }}
|
||||
@@ -69,6 +69,49 @@ jobs:
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
test-flutter-category:
|
||||
# The settings editor and the backend have to read an extension list the
|
||||
# same way, so both sides run the shared rule table. The Go side is covered
|
||||
# by the test job above, this runs the ui side of it.
|
||||
name: Test shared category rules
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Run the shared category rule table on the ui side
|
||||
working-directory: ui/flutter
|
||||
run: |
|
||||
flutter pub get
|
||||
flutter gen-l10n
|
||||
flutter test test/api/downloader_config_test.dart
|
||||
test-flutter-task-context-menu:
|
||||
name: Test task context menus and text inputs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CHROME_EXECUTABLE: /usr/bin/google-chrome
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Prepare Flutter tests
|
||||
working-directory: ui/flutter
|
||||
run: |
|
||||
flutter pub get
|
||||
flutter gen-l10n
|
||||
- name: Test task menu actions and native input policy
|
||||
working-directory: ui/flutter
|
||||
run: |
|
||||
flutter test --no-pub test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
||||
flutter test --no-pub test/widget_test.dart --plain-name 'task context menu exposes'
|
||||
- name: Test browser menu cancellation and input policy
|
||||
working-directory: ui/flutter
|
||||
run: |
|
||||
"$CHROME_EXECUTABLE" --version
|
||||
flutter test --no-pub --platform chrome test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
||||
test-webview-integration:
|
||||
name: Test webview integration (${{ matrix.name }})
|
||||
strategy:
|
||||
@@ -77,6 +120,8 @@ jobs:
|
||||
include:
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
- name: Linux
|
||||
os: ubuntu-latest
|
||||
- name: macOS
|
||||
os: macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -89,9 +134,28 @@ jobs:
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Test Windows FFmpeg memory and stream lifecycle
|
||||
if: runner.os == 'Windows'
|
||||
run: go test -v -count=1 -timeout 180s ./pkg/download/engine/ffmpeg ./pkg/download/engine/inject/stream ./pkg/download/engine
|
||||
- name: Install Linux WebView dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
||||
- name: Test webview
|
||||
if: runner.os == 'Linux'
|
||||
# Match the full suite's coverage instrumentation and repeat on the same
|
||||
# GTK event loop to catch queued callbacks after engine destruction.
|
||||
run: |
|
||||
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
||||
go test -c -tags webview -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -o "$RUNNER_TEMP/goprovider.test" ./internal/webview/goprovider
|
||||
xvfb-run -a "$RUNNER_TEMP/goprovider.test" -test.v -test.count=5 -test.timeout=300s -test.coverprofile="$RUNNER_TEMP/webview-coverage.out"
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- name: Test webview
|
||||
if: runner.os == 'Windows'
|
||||
run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/
|
||||
# Repeat close/reopen coverage to catch browser shutdown races.
|
||||
run: go test -tags webview -v -count=3 -timeout 300s ./internal/webview/goprovider/
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- name: Test webview
|
||||
|
||||
@@ -20,7 +20,7 @@ Beyond core download management, Gopeed offers browser integration, JavaScript e
|
||||
|
||||
Visit ✈ [Official Website](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex
|
||||
|
||||
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
|
||||
|
||||
- [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||
- [Download Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で
|
||||
|
||||
✈ [公式サイト](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 主な機能
|
||||
|
||||
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
|
||||
|
||||
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
||||
|
||||
- [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||
- [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ Ngoài việc quản lý tải xuống hằng ngày, Gopeed còn hỗ trợ tíc
|
||||
|
||||
Truy cập ✈ [Trang web chính thức](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ Tính năng chính
|
||||
|
||||
@@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu
|
||||
|
||||
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
|
||||
|
||||
- [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||
- [Tải Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
||||
|
||||
访问 ✈ [官方网站](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
||||
|
||||
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
||||
|
||||
- [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||
- [下载 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
||||
|
||||
瀏覽 ✈ [官方網站](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
@@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
||||
|
||||
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
|
||||
|
||||
- [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||
- [下載 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 391 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 792 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 357 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 787 KiB |
@@ -0,0 +1,52 @@
|
||||
# Batch task creation
|
||||
|
||||
`POST /api/v1/tasks/batch` accepts ordinary and grouped batches:
|
||||
|
||||
```json
|
||||
{
|
||||
"group": true,
|
||||
"groupName": "album",
|
||||
"opts": {"path": "/downloads"},
|
||||
"reqs": [
|
||||
{"rid": "resolved-resource-id", "opts": {"selectFiles": [1]}},
|
||||
{"req": {"url": "https://example.com/cover.jpg"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each member accepts `rid` or `req`. A nonempty `rid` takes priority and creates a
|
||||
task from the cached resolve result. A member's `opts`, when present, replaces
|
||||
batch `opts` as a whole; omitted fields do not inherit individual batch values.
|
||||
Members without `opts` use batch `opts`. BT file selections use the indexes from
|
||||
the resolve result. Requests without `rid` continue to work.
|
||||
|
||||
For groups, batch `opts.path` selects the parent directory. Members inheriting
|
||||
batch options, or with an empty path, use the reserved group directory. Explicit
|
||||
relative member paths are resolved under that directory; do not prefix them with
|
||||
the batch parent path. For example, parent `downloads` and member path `nested`
|
||||
place the member in `downloads/<group name>/nested`. Absolute paths under the
|
||||
parent directory preserve their relative structure within the group. In ordinary
|
||||
batches, an empty member path uses the configured default download directory.
|
||||
|
||||
All members are attempted. If at least one task is persisted, the response is
|
||||
successful and contains the created task IDs. A task that fails to start after
|
||||
being persisted is included; use the existing task controls to retry it.
|
||||
Successful tasks are kept when other members fail. If every member fails, the
|
||||
API returns an error and removes any newly reserved empty group and directory.
|
||||
|
||||
Both ordinary and grouped batches now return an object in `data`:
|
||||
|
||||
```json
|
||||
{"code": 0, "msg": "", "data": {"taskIds": ["task-1", "task-2"], "parentId": "group-id"}}
|
||||
```
|
||||
|
||||
Ordinary batches omit `parentId`. **This replaces the former array response for
|
||||
ordinary batches.** The outer `code`/`msg`/`data` envelope is unchanged. Clients
|
||||
must read `data.taskIds`; there are no per-member results. A successful response
|
||||
completes the batch, including partial success, so clients must not automatically
|
||||
resubmit missing members.
|
||||
|
||||
With `group: true`, the server generates the group ID and reserves its directory.
|
||||
Creation does not accept a client group ID or append to an existing group. Group
|
||||
metadata remains available through the existing task list and detail responses;
|
||||
`parentId` identifies membership for group operations.
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
goapi "github.com/GopeedLab/gopeed/pkg/api"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
@@ -90,3 +91,14 @@ func applyWebViewProvider(config *model.StartConfig) {
|
||||
}
|
||||
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
||||
}
|
||||
|
||||
func LiveActivityTaskEventMask() int64 {
|
||||
return int64(
|
||||
goapi.TaskEventDone |
|
||||
goapi.TaskEventError |
|
||||
goapi.TaskEventStart |
|
||||
goapi.TaskEventProgress |
|
||||
goapi.TaskEventPause |
|
||||
goapi.TaskEventDelete,
|
||||
)
|
||||
}
|
||||
+7
-1
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/GopeedLab/gopeed/cmd"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
@@ -13,5 +16,8 @@ func main() {
|
||||
Storage: model.StorageBolt,
|
||||
WebEnable: true,
|
||||
}
|
||||
cmd.Start(cfg)
|
||||
if err := cmd.Start(cfg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -166,7 +166,7 @@ func runResolve(args []string) error {
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func runDownload(args []string) error {
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ type args struct {
|
||||
url string
|
||||
connections *int
|
||||
dir *string
|
||||
checksum *string
|
||||
checksumAlgo *string
|
||||
}
|
||||
|
||||
func parse() *args {
|
||||
@@ -20,6 +22,8 @@ func parse() *args {
|
||||
var args args
|
||||
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
||||
args.dir = flag.String("D", dir, "Store directory.")
|
||||
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
|
||||
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
|
||||
flag.Parse()
|
||||
t := flag.Args()
|
||||
if len(t) > 0 {
|
||||
|
||||
+16
-1
@@ -19,6 +19,18 @@ func main() {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var checksumOpt *http.ChecksumOption
|
||||
if args.checksum != nil && *args.checksum != "" {
|
||||
algo := "md5"
|
||||
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
|
||||
algo = *args.checksumAlgo
|
||||
}
|
||||
checksumOpt = &http.ChecksumOption{
|
||||
Algorithm: algo,
|
||||
Expected: *args.checksum,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := download.Boot().
|
||||
URL(args.url).
|
||||
Listener(func(event *download.Event) {
|
||||
@@ -44,7 +56,10 @@ func main() {
|
||||
}).
|
||||
Create(&base.Options{
|
||||
Path: *args.dir,
|
||||
Extra: http.OptsExtra{Connections: *args.connections},
|
||||
Extra: http.OptsExtra{
|
||||
Connections: *args.connections,
|
||||
Checksum: checksumOpt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
+14
-6
@@ -16,17 +16,18 @@ import (
|
||||
//go:embed banner.txt
|
||||
var banner string
|
||||
|
||||
func Start(cfg *model.StartConfig) {
|
||||
fmt.Println(banner)
|
||||
func Start(cfg *model.StartConfig) error {
|
||||
srv, listener, err := rest.BuildServer(cfg)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
fmt.Println(banner)
|
||||
downloadCfg, err := rest.Downloader.GetConfig()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
listener.Close()
|
||||
return err
|
||||
}
|
||||
if downloadCfg.FirstLoad {
|
||||
if rest.Downloader.IsFirstLoad() {
|
||||
// Set default download config
|
||||
if cfg.DownloadConfig != nil {
|
||||
cfg.DownloadConfig.Merge(downloadCfg)
|
||||
@@ -51,13 +52,20 @@ func Start(cfg *model.StartConfig) {
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// The default download dir is resolved only after the core starts, so
|
||||
// seed the built-in categories now that the dir is known. Init is
|
||||
// idempotent and leaves an existing category list untouched.
|
||||
downloadCfg.Init()
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
}
|
||||
watchExit()
|
||||
|
||||
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchExit() {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
func TestStartInvalidWebAPITokenReturnsError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
webAuth *model.WebAuth
|
||||
}{
|
||||
{name: "no credentials"},
|
||||
{name: "missing username", webAuth: &model.WebAuth{Password: "secret"}},
|
||||
{name: "missing password", webAuth: &model.WebAuth{Username: "gopeed"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Start(&model.StartConfig{
|
||||
WebEnable: true,
|
||||
ApiToken: "test-token",
|
||||
WebAuth: tt.webAuth,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Start() error = nil, want web authentication validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "web authentication username and password are required") {
|
||||
t.Fatalf("Start() error = %q, want missing web authentication credentials", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,17 @@ func installByInstaller(killSignalChan chan<- any, packagePath, destDir string)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Find the installer file
|
||||
var installerPath string
|
||||
for _, file := range reader.File {
|
||||
cleanName := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
||||
continue
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract file
|
||||
path := filepath.Join(tempDir, file.Name)
|
||||
path := filepath.Join(tempDir, cleanName)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
return err
|
||||
@@ -101,7 +103,11 @@ func installByPortable(killSignalChan chan<- any, packagePath, destDir string) e
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
path := filepath.Join(destDir, file.Name)
|
||||
cleanName := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(destDir, cleanName)
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
os.MkdirAll(path, file.Mode())
|
||||
|
||||
+9
-2
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type args struct {
|
||||
TempDir *string `json:"tempDir"`
|
||||
Address *string `json:"address"`
|
||||
Port *int `json:"port"`
|
||||
Username *string `json:"username"`
|
||||
@@ -51,9 +52,10 @@ func loadCliArgs() *args {
|
||||
flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username")
|
||||
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
||||
flag.StringVar(cfg.Password, "password", "", "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")
|
||||
flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
||||
cfg.ApiToken = flag.String("T", "", "API token (requires web authentication with username and password)")
|
||||
flag.StringVar(cfg.ApiToken, "api-token", "", "API token (requires web authentication with username and password)")
|
||||
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
|
||||
cfg.TempDir = flag.String("temp-dir", "", "Temporary directory (defaults to the OS temporary directory)")
|
||||
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
||||
flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory")
|
||||
whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated")
|
||||
@@ -89,6 +91,8 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
||||
cfg.ApiToken = cliConfig.ApiToken
|
||||
case "mcp-enable":
|
||||
cfg.MCPEnable = cliConfig.MCPEnable
|
||||
case "temp-dir":
|
||||
cfg.TempDir = cliConfig.TempDir
|
||||
case "d", "storage-dir":
|
||||
cfg.StorageDir = cliConfig.StorageDir
|
||||
case "white-download-dirs":
|
||||
@@ -101,6 +105,9 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
||||
|
||||
// setDefaults sets default values for any unset configuration fields
|
||||
func setDefaults(cfg *args, cliConfig *args) {
|
||||
if cfg.TempDir == nil {
|
||||
cfg.TempDir = cliConfig.TempDir
|
||||
}
|
||||
if cfg.Address == nil {
|
||||
cfg.Address = cliConfig.Address
|
||||
}
|
||||
|
||||
+9
-1
@@ -43,11 +43,16 @@ func main() {
|
||||
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
||||
}
|
||||
|
||||
tempDir := ""
|
||||
if args.TempDir != nil {
|
||||
tempDir = *args.TempDir
|
||||
}
|
||||
cfg := &model.StartConfig{
|
||||
Network: "tcp",
|
||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: storageDir,
|
||||
TempDir: tempDir,
|
||||
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
||||
ApiToken: *args.ApiToken,
|
||||
MCPEnable: args.MCPEnable,
|
||||
@@ -57,7 +62,10 @@ func main() {
|
||||
WebFS: sub,
|
||||
WebAuth: webAuth,
|
||||
}
|
||||
cmd.Start(cfg)
|
||||
if err := cmd.Start(cfg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func isNotBlank(str *string) bool {
|
||||
|
||||
@@ -3,16 +3,20 @@ module github.com/GopeedLab/gopeed
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
||||
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914
|
||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
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/fclairamb/ftpserverlib v0.31.0
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/imroc/req/v3 v3.52.2
|
||||
github.com/imroc/req/v3 v3.58.0
|
||||
github.com/jlaffaye/ftp v0.2.4
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
github.com/mattn/go-ieproxy v0.0.12
|
||||
github.com/mholt/archives v0.1.5
|
||||
@@ -21,9 +25,17 @@ require (
|
||||
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/spf13/afero v1.15.0
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/onsi/gomega v1.36.3 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,49 +48,40 @@ require (
|
||||
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/google/jsonschema-go v0.4.3 // 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/compress v1.18.2 // 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/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // 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
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // 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/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4
|
||||
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 // 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/generics v0.2.0
|
||||
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
|
||||
@@ -94,7 +97,7 @@ require (
|
||||
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.6.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -102,13 +105,13 @@ require (
|
||||
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-git/go-billy/v5 v5.9.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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -126,7 +129,7 @@ require (
|
||||
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/dtls/v3 v3.1.4 // 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
|
||||
@@ -137,15 +140,15 @@ require (
|
||||
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/stun/v3 v3.1.5 // 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/pjbgf/sha1cd v0.6.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/skeema/knownhosts v1.2.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/tidwall/btree v1.8.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
@@ -153,13 +156,11 @@ require (
|
||||
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.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
|
||||
@@ -14,6 +14,8 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 h1:AqNFl60o4xhTTOxmzAvSf7pazXHKBGuV5ATw7sETIRA=
|
||||
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0/go.mod h1:XM6eW8CSgAuZRKRynm5nDiSNedm0rav30IychqygOHE=
|
||||
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
|
||||
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
@@ -23,17 +25,17 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914 h1:9VJQZIZ1R6T4m+4vlNQYNXSPNUv2gf2PiTfIXiQ3MTw=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
|
||||
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
|
||||
@@ -43,8 +45,6 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
|
||||
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
|
||||
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
|
||||
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
|
||||
@@ -141,7 +141,6 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w
|
||||
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
|
||||
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
@@ -158,11 +157,12 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -185,12 +185,14 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
|
||||
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fclairamb/ftpserverlib v0.31.0 h1:ws4fLJvuU/mgg1uIgRjCJfc6CrFSgIBAOxVsnawXegE=
|
||||
github.com/fclairamb/ftpserverlib v0.31.0/go.mod h1:C5hUT0sd9P5XsHMxsmpcrT6wdZexu3qtUu1z0Ih6aG0=
|
||||
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
|
||||
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
|
||||
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
|
||||
@@ -200,8 +202,8 @@ github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
||||
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
@@ -210,12 +212,12 @@ github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1T
|
||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
|
||||
github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
|
||||
github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A=
|
||||
github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo=
|
||||
github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE=
|
||||
github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
|
||||
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
@@ -236,8 +238,6 @@ github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZs
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
@@ -250,8 +250,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
@@ -276,10 +276,13 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
@@ -307,11 +310,6 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -329,10 +327,12 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
|
||||
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
github.com/imroc/req/v3 v3.52.2 h1:xJocr1aIv0a2K9knfBQ4JnZHk+kWTITdjf0mgDg229I=
|
||||
github.com/imroc/req/v3 v3.52.2/go.mod h1:dBGsDloOSZJcFs6PnTjZXYBJK70OXbZpizHBLNqcH2k=
|
||||
github.com/imroc/req/v3 v3.58.0 h1:rBqJhv2gZjMymGot4vKmNSUs/mAITMpGASqElhuMvS4=
|
||||
github.com/imroc/req/v3 v3.58.0/go.mod h1:rC7NTGgfMSkVWR4GO46rMeJsUfaeOpa6pLsgpq9q3zI=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jlaffaye/ftp v0.2.4 h1:JqI85DdkfZj8ntaHk8W9U2SC3jNfiPUU70+wtIWmlfE=
|
||||
github.com/jlaffaye/ftp v0.2.4/go.mod h1:Y1ZnkzxownGIuX7xQ1mQzzkZ21+DbjVIyeKL/V+IIz4=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@@ -345,8 +345,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -356,7 +356,6 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -369,8 +368,6 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
|
||||
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
|
||||
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
|
||||
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M=
|
||||
@@ -408,13 +405,10 @@ github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJh
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
|
||||
github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
@@ -426,8 +420,8 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
|
||||
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
|
||||
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
|
||||
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
|
||||
github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
|
||||
@@ -448,18 +442,18 @@ github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
|
||||
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
|
||||
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 h1:gnkvpxCSzeTKovDsxeFFl+W/i6WydzSHhu5lH/RpFfM=
|
||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7/go.mod h1:/td+qaV3q20qTbRjYV9ABWjmCkvt+Pog/S+NqLMoUKk=
|
||||
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -467,10 +461,7 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
@@ -490,13 +481,13 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx
|
||||
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
|
||||
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc=
|
||||
github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
|
||||
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
@@ -513,17 +504,19 @@ github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=
|
||||
github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
@@ -550,8 +543,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
||||
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
||||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
@@ -590,12 +585,12 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
|
||||
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@@ -605,11 +600,9 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -619,8 +612,8 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -642,8 +635,8 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -665,14 +658,12 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -689,8 +680,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -723,24 +714,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -748,13 +735,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
@@ -787,8 +772,8 @@ golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapK
|
||||
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -833,8 +818,6 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/internal/production"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -61,6 +62,7 @@ type Metadata struct {
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
producer production.Reader
|
||||
ID string
|
||||
URL string
|
||||
ContentType string
|
||||
@@ -359,11 +361,35 @@ func (r *Registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
if producer, ok := reader.(production.Reader); ok {
|
||||
src.mu.Lock()
|
||||
src.producer = producer
|
||||
src.mu.Unlock()
|
||||
if err := producer.WaitProduction(req.Context()); err != nil {
|
||||
return
|
||||
}
|
||||
if producer.Production() != nil {
|
||||
meta.Size = 0
|
||||
meta.Range = false
|
||||
start = 0
|
||||
end = -1
|
||||
ranged = false
|
||||
src.mu.Lock()
|
||||
src.size = 0
|
||||
src.rangeEnabled = false
|
||||
src.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
writeHeaders(w, meta, start, end, ranged)
|
||||
if ranged {
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
}
|
||||
if producer, ok := reader.(production.Reader); ok && producer.Production() != nil {
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
limit := int64(-1)
|
||||
if end >= start {
|
||||
limit = end - start + 1
|
||||
@@ -675,3 +701,21 @@ func randomID(size int) (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Production returns the active opener's input counter, if it generates output.
|
||||
func (r *Registry) Production(raw string) production.Source {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
src.mu.Lock()
|
||||
reader := src.producer
|
||||
src.mu.Unlock()
|
||||
if reader == nil {
|
||||
return nil
|
||||
}
|
||||
return reader.Production()
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/tempfiles"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -9,8 +11,14 @@ import (
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
TempFiles *tempfiles.Scope
|
||||
ManagedProduction func(string) bool
|
||||
TempDir string
|
||||
GetConfig func(v any)
|
||||
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
|
||||
// Logger is the downloader's core logger. It may be nil, in which case
|
||||
// fetchers log nothing.
|
||||
Logger *zerolog.Logger
|
||||
FileController
|
||||
//ContextDialer() (proxy.Dialer, error)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// Each download task will have a corresponding Fetcher instance for the management of the download task
|
||||
type Fetcher interface {
|
||||
Setup(ctl *controller.Controller)
|
||||
Resolve(req *base.Request, opts *base.Options) error
|
||||
Resolve(req *base.Request) error
|
||||
Start() error
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP: can modify Request info (URL, headers, etc.)
|
||||
@@ -46,6 +46,10 @@ type Uploader interface {
|
||||
WaitUpload() error
|
||||
}
|
||||
|
||||
type DownloadCounter interface {
|
||||
ReceivedBytes() int64
|
||||
}
|
||||
|
||||
// FetcherMeta defines the meta information of a fetcher.
|
||||
type FetcherMeta struct {
|
||||
Req *base.Request `json:"req"`
|
||||
@@ -106,7 +110,13 @@ func (s *SchemeFilter) Match(uri string) bool {
|
||||
case FilterTypeUrl:
|
||||
return strings.HasPrefix(uriUpper, patternUpper+":")
|
||||
case FilterTypeFile:
|
||||
return strings.HasSuffix(uriUpper, "."+patternUpper)
|
||||
// Strip query and fragment before matching so URLs like
|
||||
// https://example.com/video.m3u8?sign=abc still route by extension.
|
||||
clean := uri
|
||||
if idx := strings.IndexAny(clean, "?#"); idx >= 0 {
|
||||
clean = clean[:idx]
|
||||
}
|
||||
return strings.HasSuffix(strings.ToUpper(clean), "."+patternUpper)
|
||||
case FilterTypeBase64:
|
||||
return strings.HasPrefix(uriUpper, "DATA:"+patternUpper+";BASE64,")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TriggerSource string
|
||||
|
||||
const (
|
||||
TriggerScheduled TriggerSource = "scheduled"
|
||||
TriggerManual TriggerSource = "manual"
|
||||
)
|
||||
|
||||
var ErrJobSkipped = errors.New("protocol job skipped")
|
||||
var ErrConfigChanged = errors.New("protocol configuration changed during execution")
|
||||
|
||||
// ConfigState embeds the common backend-owned state section in a protocol config.
|
||||
// Public config PUTs always retain this section from the latest stored config.
|
||||
type ConfigState[T any] struct {
|
||||
State T `json:"state"`
|
||||
}
|
||||
|
||||
// ProtocolHost is scoped to one manager. UpdateState runs against the latest
|
||||
// config under Downloader's lock; callbacks must not perform network I/O.
|
||||
type ProtocolHost struct {
|
||||
Read func(any) error
|
||||
UpdateState func(func(config, state json.RawMessage) (any, error)) error
|
||||
Fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)
|
||||
}
|
||||
|
||||
func ReadConfig[T any](host ProtocolHost) (*T, error) {
|
||||
var cfg T
|
||||
if err := host.Read(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpdateState gives the callback a detached config snapshot and mutable state.
|
||||
// Only the state is committed; edits to the config snapshot are discarded.
|
||||
func UpdateState[C, S any](host ProtocolHost, update func(*C, *S) error) error {
|
||||
return host.UpdateState(func(rawConfig, rawState json.RawMessage) (any, error) {
|
||||
var cfg C
|
||||
var state S
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(rawState, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := update(&cfg, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
})
|
||||
}
|
||||
|
||||
type CronJob struct {
|
||||
Name string
|
||||
Every time.Duration
|
||||
Timeout time.Duration
|
||||
Run func(context.Context, TriggerSource) error
|
||||
}
|
||||
type CronProvider interface{ CronJobs(ProtocolHost) []CronJob }
|
||||
|
||||
// ConfigNormalizer is optional. It owns protocol validation and derived fields.
|
||||
type ConfigNormalizer interface {
|
||||
NormalizeConfig(json.RawMessage) (any, error)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# GitHub mirror execution
|
||||
|
||||
This package owns GitHub URL rewriting and execution strategies. It depends only
|
||||
on the standard library and does not depend on Downloader, HTTP clients, Git, or
|
||||
storage. Downloader persists `Config` at its top-level `githubMirror` field;
|
||||
`pkg/base` preserves the existing configuration names through aliases.
|
||||
|
||||
## Race: resource reads
|
||||
|
||||
```go
|
||||
body, err := githubmirror.Race(ctx, cfg.GithubMirror, rawURL,
|
||||
githubmirror.Options[[]byte]{
|
||||
Kind: githubmirror.Resource,
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
func(ctx context.Context, candidate string) ([]byte, error) {
|
||||
return fetchAndValidate(ctx, client, candidate)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The callback owns HTTP requests, size limits, and content validation. The first
|
||||
completed valid result wins. Other operations are canceled and drained. There is
|
||||
no HEAD-probe assumption or client-specific HTTP behavior inside the executor.
|
||||
|
||||
## Fallback: repository operations
|
||||
|
||||
```go
|
||||
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
|
||||
githubmirror.Options[*git.Repository]{
|
||||
Kind: githubmirror.Repository,
|
||||
},
|
||||
func(ctx context.Context, candidate string) (*git.Repository, error) {
|
||||
// Use a clean temporary checkout for each attempt.
|
||||
return clone(ctx, candidate)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The official URL is attempted first; mirrors follow their configured order.
|
||||
jsDelivr is excluded for repositories. Stop on the first success. Perform
|
||||
persistent installation or other business state changes after success, rather
|
||||
than inside a potentially repeated attempt.
|
||||
|
||||
## Results with resources
|
||||
|
||||
For temporary files, set `Options[*Download].Discard` to remove the file. Return
|
||||
the partial `*Download` alongside errors, so cleanup also covers failed reads or
|
||||
validation. The executor discards every failure and every race loser exactly
|
||||
once. The caller owns and must track/release the winning result.
|
||||
|
||||
- `Timeout == 0` uses 30 seconds per resource attempt and no overall deadline
|
||||
for repository attempts; a negative timeout is invalid. Extension Git HTTP
|
||||
transport limits connections/TLS handshakes to 10 seconds and resets a 60-second
|
||||
read deadline as data arrives. Slow continuous transfers can finish, while
|
||||
stalled sources fail and allow ordered fallback. Parent cancellation still applies.
|
||||
- Attempts must honor the supplied context; draining waits for them to return.
|
||||
- Attempt contexts are canceled after return, so callbacks must finish reading
|
||||
HTTP bodies before returning. Returning a still-streaming response is unsupported.
|
||||
- Discard callbacks must accept zero values, must not panic, and run serially.
|
||||
- Parent cancellation wins over a result selected while the parent is canceled;
|
||||
the selected result is then discarded as well.
|
||||
- Errors preserve their causes through wrapping/joining for `errors.Is`.
|
||||
- `github.com` and `raw.githubusercontent.com` expand into eligible mirrors.
|
||||
jsDelivr is only eligible for raw source resources. `api.github.com` is never
|
||||
mirrored and is rejected by the public mirror candidates API.
|
||||
- Client application update checks request the official GitHub API directly,
|
||||
falling back to the independent Gopeed website API on request or validation
|
||||
failure. Only package downloads obtain URLs from
|
||||
`POST /api/v1/mirrors/github/candidates`. Candidate generation never performs
|
||||
HTTP requests.
|
||||
- Disabled mirrors and other URLs execute only the original URL.
|
||||
- Config is a caller-owned immutable snapshot for the duration of a call.
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package githubmirror rewrites GitHub URLs and executes mirrored operations.
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mirror struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
IsBuiltIn bool `json:"isBuiltIn"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
}
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mirrors []Mirror `json:"mirrors"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{Enabled: true, Mirrors: []Mirror{
|
||||
{Type: "jsdelivr", URL: "https://fastly.jsdelivr.net/gh", IsBuiltIn: true},
|
||||
{Type: "ghProxy", URL: "https://fastgit.cc", IsBuiltIn: true},
|
||||
{Type: "ghProxy", URL: "https://gh-proxy.com", IsBuiltIn: true},
|
||||
{Type: "ghProxy", URL: "https://ghfast.top", IsBuiltIn: true},
|
||||
}}
|
||||
}
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Mirrors) > 32 {
|
||||
return fmt.Errorf("too many GitHub mirrors")
|
||||
}
|
||||
for _, mirror := range c.Mirrors {
|
||||
u, err := url.Parse(mirror.URL)
|
||||
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("invalid GitHub mirror URL")
|
||||
}
|
||||
if mirror.Type != "jsdelivr" && mirror.Type != "ghProxy" {
|
||||
return fmt.Errorf("invalid GitHub mirror type: %s", mirror.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kind describes what a candidate endpoint must support.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
Resource Kind = iota
|
||||
Repository
|
||||
)
|
||||
|
||||
// Candidates preserves the official URL first. jsDelivr is only eligible for
|
||||
// raw source resources; Git operations use mirrors supporting Git Smart HTTP.
|
||||
// GitHub API URLs are never mirrored.
|
||||
func Candidates(c *Config, raw string, kind Kind) []string {
|
||||
result := []string{raw}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || c == nil || !c.Enabled || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return result
|
||||
}
|
||||
if u.Host != "github.com" && u.Host != "raw.githubusercontent.com" {
|
||||
return result
|
||||
}
|
||||
seen := map[string]bool{raw: true}
|
||||
for _, mirror := range c.Mirrors {
|
||||
if mirror.IsDeleted {
|
||||
continue
|
||||
}
|
||||
var candidate string
|
||||
switch mirror.Type {
|
||||
case "ghProxy":
|
||||
candidate = strings.TrimRight(mirror.URL, "/") + "/" + raw
|
||||
case "jsdelivr":
|
||||
if kind == Repository || u.Host != "raw.githubusercontent.com" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(u.EscapedPath(), "/"), "/", 4)
|
||||
if len(parts) != 4 {
|
||||
continue
|
||||
}
|
||||
candidate = strings.TrimRight(mirror.URL, "/") + "/" + parts[0] + "/" + parts[1] + "@" + parts[2] + "/" + parts[3]
|
||||
}
|
||||
if candidate != "" && !seen[candidate] {
|
||||
result = append(result, candidate)
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMirrorCandidates(t *testing.T) {
|
||||
cfg := &Config{Enabled: true, Mirrors: []Mirror{
|
||||
{Type: "jsdelivr", URL: "https://cdn.example/gh/"},
|
||||
{Type: "ghProxy", URL: "https://mirror.example/"},
|
||||
{Type: "ghProxy", URL: "https://deleted.example", IsDeleted: true},
|
||||
}}
|
||||
source := "https://raw.githubusercontent.com/owner/repo/main/a.txt"
|
||||
want := []string{source, "https://cdn.example/gh/owner/repo@main/a.txt", "https://mirror.example/" + source}
|
||||
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
repo := "https://github.com/owner/repo.git"
|
||||
if got := Candidates(cfg, repo, Repository); !reflect.DeepEqual(got, []string{repo, "https://mirror.example/" + repo}) {
|
||||
t.Fatalf("git candidates %v", got)
|
||||
}
|
||||
for _, raw := range []string{"https://github.com.evil.example/a", "https://example.com/a", "https://user:[email protected]/a"} {
|
||||
if got := Candidates(cfg, raw, Resource); !reflect.DeepEqual(got, []string{raw}) {
|
||||
t.Fatalf("unexpected mirror: %v", got)
|
||||
}
|
||||
}
|
||||
apiURL := "https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10"
|
||||
for _, kind := range []Kind{Resource, Repository} {
|
||||
if got := Candidates(cfg, apiURL, kind); !reflect.DeepEqual(got, []string{apiURL}) {
|
||||
t.Fatalf("GitHub API must never use mirrors: %v", got)
|
||||
}
|
||||
}
|
||||
cfg.Enabled = false
|
||||
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, []string{source}) {
|
||||
t.Fatalf("disabled mirror: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options configures execution, not URL persistence. Discard releases results
|
||||
// from failed attempts and successful race losers, including partial results
|
||||
// returned together with an error. The caller owns the winning result.
|
||||
type Options[T any] struct {
|
||||
Kind Kind
|
||||
// Timeout limits each attempt. Zero uses 30 seconds for resources and no
|
||||
// overall deadline for repositories, whose transport owns idle timeouts.
|
||||
Timeout time.Duration
|
||||
Discard func(T)
|
||||
}
|
||||
|
||||
// Attempt must honor ctx and return a completed, validated result. Its context
|
||||
// is canceled after it returns, so live response streams must not be returned.
|
||||
// Return any partially acquired resources along with errors so Discard can
|
||||
// release them. Failed attempts must not mutate persistent business state.
|
||||
type Attempt[T any] func(context.Context, string) (T, error)
|
||||
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
func prepare[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) ([]string, time.Duration, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, 0, errors.New("empty GitHub source URL")
|
||||
}
|
||||
if options.Kind != Resource && options.Kind != Repository {
|
||||
return nil, 0, errors.New("invalid GitHub resource kind")
|
||||
}
|
||||
if options.Timeout < 0 {
|
||||
return nil, 0, errors.New("invalid GitHub attempt timeout")
|
||||
}
|
||||
if attempt == nil {
|
||||
return nil, 0, errors.New("GitHub mirror attempt is required")
|
||||
}
|
||||
if config != nil {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
timeout := options.Timeout
|
||||
if timeout == 0 && options.Kind == Resource {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
return Candidates(config, raw, options.Kind), timeout, nil
|
||||
}
|
||||
func invoke[T any](ctx context.Context, timeout time.Duration, candidate string, attempt Attempt[T]) (value T, err error) {
|
||||
var cancel context.CancelFunc
|
||||
if timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
}
|
||||
defer cancel()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("GitHub mirror attempt panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
value, err = attempt(ctx, candidate)
|
||||
if err == nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return
|
||||
}
|
||||
func discard[T any](options Options[T], value T) {
|
||||
if options.Discard != nil {
|
||||
options.Discard(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Race executes the official URL and eligible mirrors concurrently. The first
|
||||
// completed successful result wins; losing attempts are canceled and drained
|
||||
// before return so no temporary files or other resources outlive the call.
|
||||
// HTTP status/content validation is the attempt's responsibility.
|
||||
func Race[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
|
||||
var zero T
|
||||
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
raceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
type outcome struct {
|
||||
index int
|
||||
value T
|
||||
err error
|
||||
}
|
||||
outcomes := make(chan outcome, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
go func(index int, candidate string) {
|
||||
value, err := invoke(raceCtx, timeout, candidate, attempt)
|
||||
outcomes <- outcome{index, value, err}
|
||||
}(index, candidate)
|
||||
}
|
||||
var winner T
|
||||
won := false
|
||||
failures := make([]error, len(candidates))
|
||||
for range candidates {
|
||||
outcome := <-outcomes
|
||||
if !won && outcome.err == nil && ctx.Err() == nil {
|
||||
winner = outcome.value
|
||||
won = true
|
||||
cancel()
|
||||
} else {
|
||||
discard(options, outcome.value)
|
||||
if outcome.err != nil {
|
||||
failures[outcome.index] = fmt.Errorf("GitHub source %d: %w", outcome.index+1, outcome.err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
if won {
|
||||
discard(options, winner)
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
if won {
|
||||
return winner, nil
|
||||
}
|
||||
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
// Fallback executes the official URL first, then eligible mirrors in their
|
||||
// configured order. It stops immediately on success and cleans every failure.
|
||||
func Fallback[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
|
||||
var zero T
|
||||
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
failures := make([]error, 0, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
value, err := invoke(ctx, timeout, candidate, attempt)
|
||||
if err == nil && ctx.Err() == nil {
|
||||
return value, nil
|
||||
}
|
||||
discard(options, value)
|
||||
if parentErr := ctx.Err(); parentErr != nil {
|
||||
return zero, parentErr
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("GitHub source %d: %w", index+1, err))
|
||||
}
|
||||
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testSource = "https://github.com/owner/repo.git"
|
||||
|
||||
func testConfig() *Config {
|
||||
return &Config{Enabled: true, Mirrors: []Mirror{
|
||||
{Type: "ghProxy", URL: "https://first.example"},
|
||||
{Type: "ghProxy", URL: "https://second.example"},
|
||||
}}
|
||||
}
|
||||
func TestRaceWinsAndDrainsOwnedResults(t *testing.T) {
|
||||
config := testConfig()
|
||||
candidates := Candidates(config, testSource, Resource)
|
||||
var started sync.WaitGroup
|
||||
started.Add(len(candidates))
|
||||
discarded := []string{}
|
||||
winner, err := Race(context.Background(), config, testSource, Options[string]{
|
||||
Discard: func(value string) { discarded = append(discarded, value) },
|
||||
}, func(ctx context.Context, address string) (string, error) {
|
||||
started.Done()
|
||||
started.Wait()
|
||||
switch address {
|
||||
case candidates[0]:
|
||||
return address, errors.New("official offline")
|
||||
case candidates[1]:
|
||||
return address, nil
|
||||
default:
|
||||
<-ctx.Done()
|
||||
return address, ctx.Err()
|
||||
}
|
||||
})
|
||||
if err != nil || winner != candidates[1] {
|
||||
t.Fatalf("winner=%s err=%v", winner, err)
|
||||
}
|
||||
if len(discarded) != 2 || !contains(discarded, candidates[0]) || !contains(discarded, candidates[2]) {
|
||||
t.Fatalf("discarded %v", discarded)
|
||||
}
|
||||
}
|
||||
func contains(values []string, value string) bool {
|
||||
for _, item := range values {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func TestRaceCleansSimultaneousSuccessfulLosers(t *testing.T) {
|
||||
config := testConfig()
|
||||
var started sync.WaitGroup
|
||||
started.Add(3)
|
||||
cleaned := []string{}
|
||||
winner, err := Race(context.Background(), config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
|
||||
started.Done()
|
||||
started.Wait()
|
||||
return address, nil
|
||||
})
|
||||
if err != nil || winner == "" || len(cleaned) != 2 || contains(cleaned, winner) {
|
||||
t.Fatalf("winner=%s discarded=%v err=%v", winner, cleaned, err)
|
||||
}
|
||||
}
|
||||
func TestRaceParentCancellationCleansWinnerToo(t *testing.T) {
|
||||
config := testConfig()
|
||||
config.Mirrors = config.Mirrors[:1]
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
cleaned := []string{}
|
||||
winner, err := Race(ctx, config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(ctx context.Context, address string) (string, error) {
|
||||
if address == testSource {
|
||||
return address, nil
|
||||
}
|
||||
<-ctx.Done()
|
||||
cancel()
|
||||
return address, ctx.Err()
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || winner != "" || len(cleaned) != 2 || !contains(cleaned, testSource) {
|
||||
t.Fatalf("winner=%s cleanup=%v err=%v", winner, cleaned, err)
|
||||
}
|
||||
}
|
||||
func TestRaceTimeoutAndErrorAggregation(t *testing.T) {
|
||||
expected := errors.New("offline")
|
||||
config := testConfig()
|
||||
config.Mirrors = config.Mirrors[:1]
|
||||
discarded := 0
|
||||
_, err := Race(context.Background(), config, testSource, Options[string]{Timeout: 10 * time.Millisecond, Discard: func(string) { discarded++ }}, func(ctx context.Context, address string) (string, error) {
|
||||
if address == testSource {
|
||||
return address, expected
|
||||
}
|
||||
<-ctx.Done()
|
||||
return address, ctx.Err()
|
||||
})
|
||||
if !errors.Is(err, expected) || !errors.Is(err, context.DeadlineExceeded) || discarded != 2 {
|
||||
t.Fatalf("discarded=%d err=%v", discarded, err)
|
||||
}
|
||||
}
|
||||
func TestFallbackPreservesOrderAndStopsOnSuccess(t *testing.T) {
|
||||
config := testConfig()
|
||||
candidates := Candidates(config, testSource, Repository)
|
||||
tried := []string{}
|
||||
cleaned := []string{}
|
||||
winner, err := Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository, Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
|
||||
tried = append(tried, address)
|
||||
if address == candidates[1] {
|
||||
return address, nil
|
||||
}
|
||||
return address, errors.New("offline")
|
||||
})
|
||||
if err != nil || winner != candidates[1] || !reflect.DeepEqual(tried, candidates[:2]) || !reflect.DeepEqual(cleaned, candidates[:1]) {
|
||||
t.Fatalf("winner=%s tried=%v discarded=%v err=%v", winner, tried, cleaned, err)
|
||||
}
|
||||
tried = nil
|
||||
_, err = Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository}, func(_ context.Context, address string) (string, error) {
|
||||
tried = append(tried, address)
|
||||
return address, nil
|
||||
})
|
||||
if err != nil || !reflect.DeepEqual(tried, candidates[:1]) {
|
||||
t.Fatalf("tried=%v err=%v", tried, err)
|
||||
}
|
||||
}
|
||||
func TestFallbackTimeoutContinuesWithFreshContext(t *testing.T) {
|
||||
discarded := []string{}
|
||||
winner, err := Fallback(context.Background(), testConfig(), testSource, Options[string]{Kind: Repository, Timeout: 10 * time.Millisecond, Discard: func(value string) { discarded = append(discarded, value) }}, func(ctx context.Context, address string) (string, error) {
|
||||
if address == testSource {
|
||||
<-ctx.Done()
|
||||
return address, ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return address, err
|
||||
}
|
||||
return address, nil
|
||||
})
|
||||
if err != nil || !strings.HasPrefix(winner, "https://first.example/") || !reflect.DeepEqual(discarded, []string{testSource}) {
|
||||
t.Fatalf("winner=%s cleanup=%v err=%v", winner, discarded, err)
|
||||
}
|
||||
}
|
||||
func TestFallbackCancellationStopsBeforeNextMirror(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
calls := 0
|
||||
cleaned := 0
|
||||
_, err := Fallback(ctx, testConfig(), testSource, Options[string]{Kind: Repository, Discard: func(string) { cleaned++ }}, func(ctx context.Context, address string) (string, error) {
|
||||
calls++
|
||||
cancel()
|
||||
return address, ctx.Err()
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 || cleaned != 1 {
|
||||
t.Fatalf("calls=%d discarded=%d err=%v", calls, cleaned, err)
|
||||
}
|
||||
}
|
||||
func TestExecutorsDoNotStartInvalidOrCanceledRequests(t *testing.T) {
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for _, test := range []struct {
|
||||
ctx context.Context
|
||||
config *Config
|
||||
options Options[string]
|
||||
attempt Attempt[string]
|
||||
}{
|
||||
{ctx: canceled, options: Options[string]{}, attempt: func(context.Context, string) (string, error) { t.Fatal("called after cancellation"); return "", nil }},
|
||||
{ctx: context.Background(), options: Options[string]{Kind: 99}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid kind"); return "", nil }},
|
||||
{ctx: context.Background(), options: Options[string]{Timeout: -time.Second}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid timeout"); return "", nil }},
|
||||
{ctx: context.Background(), options: Options[string]{}, attempt: nil},
|
||||
{ctx: context.Background(), config: &Config{Mirrors: []Mirror{{Type: "invalid", URL: "https://mirror.example"}}}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid config"); return "", nil }},
|
||||
} {
|
||||
if _, err := Race(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
|
||||
t.Fatal("invalid race accepted")
|
||||
}
|
||||
if _, err := Fallback(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
|
||||
t.Fatal("invalid fallback accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAttemptPanicBecomesError(t *testing.T) {
|
||||
attempt := func(context.Context, string) (string, error) { panic("broken operation") }
|
||||
if _, err := Race(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Fallback(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestNonGithubResourcesAndDisabledMirrorsUseOnlyOriginal(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
config *Config
|
||||
raw string
|
||||
}{{testConfig(), "https://example.com/resource"}, {&Config{Enabled: false, Mirrors: testConfig().Mirrors}, testSource}} {
|
||||
calls := 0
|
||||
got, err := Race(context.Background(), test.config, test.raw, Options[string]{}, func(_ context.Context, address string) (string, error) { calls++; return address, nil })
|
||||
if err != nil || got != test.raw || calls != 1 {
|
||||
t.Fatalf("got=%s calls=%d err=%v", got, calls, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryAttemptsHaveNoDefaultDeadline(t *testing.T) {
|
||||
value, err := Fallback(context.Background(), nil, testSource, Options[string]{Kind: Repository}, func(ctx context.Context, raw string) (string, error) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
t.Fatal("repository attempt has a total deadline")
|
||||
}
|
||||
return raw, nil
|
||||
})
|
||||
if err != nil || value != testSource {
|
||||
t.Fatalf("value=%s error=%v", value, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package production associates generated resources with their input progress.
|
||||
package production
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Source counts unique input bytes and total received bytes (including refetches).
|
||||
// Implementations must support concurrent snapshots.
|
||||
type Source interface {
|
||||
Progress() (downloaded, received int64)
|
||||
}
|
||||
|
||||
// Reader resolves its optional producer before response headers are sent.
|
||||
// A nil producer preserves ordinary Blob behavior.
|
||||
type Reader interface {
|
||||
Production() Source
|
||||
WaitProduction(context.Context) error
|
||||
}
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
sources map[string]Source
|
||||
}
|
||||
|
||||
func (r *Registry) Set(id string, source Source) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.sources == nil {
|
||||
r.sources = make(map[string]Source)
|
||||
}
|
||||
r.sources[id] = source
|
||||
}
|
||||
func (r *Registry) Get(id string) Source { r.mu.Lock(); defer r.mu.Unlock(); return r.sources[id] }
|
||||
func (r *Registry) Remove(id string) { r.mu.Lock(); defer r.mu.Unlock(); delete(r.sources, id) }
|
||||
@@ -1,12 +1,37 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
type trackerSubscriptionStatus struct {
|
||||
Success bool `json:"success"`
|
||||
TrackerCount int `json:"trackerCount"`
|
||||
LastFetchTime time.Time `json:"lastFetchTime"`
|
||||
}
|
||||
|
||||
type state struct {
|
||||
TrackerSubscriptionStatuses map[string]trackerSubscriptionStatus `json:"trackerSubscriptionStatuses"`
|
||||
TrackerSources map[string][]string `json:"trackerSources"`
|
||||
LastTrackerUpdateTime time.Time `json:"lastTrackerUpdateTime"`
|
||||
}
|
||||
|
||||
type config struct {
|
||||
fetcher.ConfigState[state]
|
||||
TrackerSubscribeURLs []string `json:"trackerSubscribeUrls"`
|
||||
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
|
||||
CustomTrackers []string `json:"customTrackers"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
Trackers []string `json:"trackers"`
|
||||
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
||||
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.
|
||||
SeedTime int64 `json:"seedTime"`
|
||||
// DisableDHT and DisablePEX turn off DHT and PEX peer discovery. Like ListenPort,
|
||||
// they are read when the shared client is built.
|
||||
DisableDHT bool `json:"disableDht"`
|
||||
DisablePEX bool `json:"disablePex"`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+358
-115
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
@@ -18,11 +19,11 @@ import (
|
||||
)
|
||||
|
||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||
doResolve(t, buildFetcher())
|
||||
doResolve(t, buildFetcher(t))
|
||||
}
|
||||
|
||||
func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
fetcher := buildFetcher()
|
||||
fetcher := buildFetcher(t)
|
||||
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -31,7 +32,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)
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_Config(t *testing.T) {
|
||||
doResolve(t, buildConfigFetcher(nil))
|
||||
doResolve(t, buildConfigFetcher(t, nil))
|
||||
}
|
||||
|
||||
func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
@@ -61,7 +62,7 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
proxyListener := test.StartSocks5Server(usr, pwd)
|
||||
defer proxyListener.Close()
|
||||
|
||||
doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{
|
||||
doResolve(t, buildConfigFetcher(t, &base.DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
System: false,
|
||||
Scheme: "socks5",
|
||||
@@ -71,6 +72,82 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
}))
|
||||
}
|
||||
|
||||
func TestFetcher_CloseAfterInit(t *testing.T) {
|
||||
if err := closeClient(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
f := buildFetcher(t).(*Fetcher)
|
||||
f.meta.Req = &base.Request{}
|
||||
lock.Lock()
|
||||
err := f.initClient()
|
||||
lock.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closeClient(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_CloseWithoutClient(t *testing.T) {
|
||||
if err := closeClient(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("restored task", func(t *testing.T) {
|
||||
_, build := new(FetcherManager).Restore()
|
||||
f := build(&fetcher.FetcherMeta{
|
||||
Req: &base.Request{URL: "./testdata/test.torrent"},
|
||||
Opts: &base.Options{},
|
||||
}, &fetcherData{})
|
||||
f.Setup(controller.NewController())
|
||||
if err := f.Close(); err != nil {
|
||||
t.Errorf("Close() got = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failed resolve", func(t *testing.T) {
|
||||
f := buildFetcher(t)
|
||||
if err := f.Resolve(&base.Request{URL: "./testdata/not-exist.torrent"}); err == nil {
|
||||
t.Fatal("Resolve() got = nil, want error")
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Errorf("Close() got = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetcher_ResolveWhileClientCloses(t *testing.T) {
|
||||
if err := closeClient(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { closeClient() })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, u := range []string{"./testdata/test.torrent", "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent"} {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 10; i++ {
|
||||
f := buildFetcher(t)
|
||||
// The other goroutine may close the client after the torrent was added.
|
||||
if err := f.Resolve(&base.Request{URL: u}); err != nil && err.Error() != "torrent closed during resolution" {
|
||||
t.Errorf("Resolve(%s) got = %v", u, err)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
if err := closeClient(); err != nil {
|
||||
t.Errorf("closeClient() got = %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
t.Run("Resolve Single File", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
@@ -81,7 +158,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -111,7 +188,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -145,7 +222,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)
|
||||
}
|
||||
@@ -156,7 +233,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)
|
||||
}
|
||||
@@ -211,7 +288,7 @@ func TestFetcherManager_ParseName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildFetcher() fetcher.Fetcher {
|
||||
func buildFetcher(t testing.TB) fetcher.Fetcher {
|
||||
fb := new(FetcherManager)
|
||||
fetcher := fb.Build()
|
||||
newController := controller.NewController()
|
||||
@@ -219,14 +296,15 @@ func buildFetcher() fetcher.Fetcher {
|
||||
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||
return fetcher
|
||||
}
|
||||
|
||||
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||
func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||
fetcher := new(FetcherManager).Build()
|
||||
newController := controller.NewController()
|
||||
mockCfg := config{
|
||||
Trackers: []string{
|
||||
CustomTrackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
}}
|
||||
@@ -237,29 +315,30 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
||||
return proxyConfig.ToHandler()
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||
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()
|
||||
f := buildFetcher(t)
|
||||
|
||||
// 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
|
||||
// Resolution describes files without choosing download options.
|
||||
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))
|
||||
if meta.Opts != nil {
|
||||
t.Fatal("Resolve should not assign download options")
|
||||
}
|
||||
|
||||
// Total size of all files: 107484864
|
||||
@@ -366,3 +445,52 @@ func TestFetcher_Patch(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetcher_PeerDiscoveryConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stored any
|
||||
wantDisableDHT bool
|
||||
wantDisablePEX bool
|
||||
}{
|
||||
{"default config", new(FetcherManager).DefaultConfig(), false, false},
|
||||
{"stored config without the fields", map[string]any{"listenPort": 0, "seedRatio": 1.0}, false, false},
|
||||
{"both disabled", map[string]any{"disableDht": true, "disablePex": true}, true, true},
|
||||
{"only DHT disabled", map[string]any{"disableDht": true}, true, false},
|
||||
{"only PEX disabled", map[string]any{"disablePex": true}, false, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := buildFetcherWithConfig(tt.stored).(*Fetcher)
|
||||
if f.config.DisableDHT != tt.wantDisableDHT || f.config.DisablePEX != tt.wantDisablePEX {
|
||||
t.Errorf("config got DisableDHT = %v, DisablePEX = %v, want %v, %v", f.config.DisableDHT, f.config.DisablePEX, tt.wantDisableDHT, tt.wantDisablePEX)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_PeerDiscoveryDisabledOnClient(t *testing.T) {
|
||||
if err := closeClient(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := buildFetcherWithConfig(map[string]any{"disableDht": true, "disablePex": true})
|
||||
if err := f.Resolve(&base.Request{URL: "./testdata/test.torrent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.NoDHT || !cfg.DisablePEX {
|
||||
t.Errorf("client config got NoDHT = %v, DisablePEX = %v, want true", cfg.NoDHT, cfg.DisablePEX)
|
||||
}
|
||||
if n := len(client.DhtServers()); n != 0 {
|
||||
t.Errorf("client got %d DHT servers, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFetcherWithConfig(stored any) fetcher.Fetcher {
|
||||
fetcher := new(FetcherManager).Build()
|
||||
newController := controller.NewController()
|
||||
newController.GetConfig = func(v any) {
|
||||
json.Unmarshal([]byte(test.ToJson(stored)), v)
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
return fetcher
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/merkle"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// btFixture serves generated, trackerless content over a real loopback TCP peer.
|
||||
// Its HTTP tracker exercises .torrent downloads; magnet links also carry the
|
||||
// explicit x.pe address.
|
||||
type btFixture struct {
|
||||
MetaInfo *metainfo.MetaInfo
|
||||
Magnet string
|
||||
TorrentFile string
|
||||
DataURI string
|
||||
TrackerURL string
|
||||
Files map[string][]byte // Paths relative to the selected download directory.
|
||||
Seeder *torrent.Client
|
||||
Torrent *torrent.Torrent
|
||||
Limiter *rate.Limiter
|
||||
}
|
||||
|
||||
func newBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int) *btFixture {
|
||||
return makeBTFixture(t, files, bytesPerSecond, false, true)
|
||||
}
|
||||
|
||||
func newBTHybridFixture(t testing.TB, payload []byte) *btFixture {
|
||||
return makeBTFixture(t, map[string][]byte{"": payload}, 0, true, true)
|
||||
}
|
||||
|
||||
func makeBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool) *btFixture {
|
||||
t.Helper()
|
||||
return makeBTFixtureWithPieceSize(t, files, bytesPerSecond, v2, private, 16*1024)
|
||||
}
|
||||
|
||||
func makeBTFixtureWithPieceSize(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool, pieceSize int64) *btFixture {
|
||||
t.Helper()
|
||||
source := t.TempDir()
|
||||
name := "payload.bin"
|
||||
if _, single := files[""]; !single {
|
||||
name = "bundle"
|
||||
}
|
||||
root := filepath.Join(source, name)
|
||||
expected := make(map[string][]byte, len(files))
|
||||
for path, data := range files {
|
||||
target := filepath.Join(root, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected[filepath.Join(name, filepath.FromSlash(path))] = append([]byte(nil), data...)
|
||||
}
|
||||
info := metainfo.Info{PieceLength: pieceSize}
|
||||
if err := info.BuildFromFilePath(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var layers map[string]string
|
||||
if v2 {
|
||||
data := files[""]
|
||||
h := merkle.NewHash()
|
||||
h.Write(data)
|
||||
root := h.Sum(nil)
|
||||
var pieceHashes []byte
|
||||
for off := 0; off < len(data); off += int(info.PieceLength) {
|
||||
end := min(off+int(info.PieceLength), len(data))
|
||||
ph := merkle.NewHash()
|
||||
ph.Write(data[off:end])
|
||||
pieceHashes = append(pieceHashes, ph.SumMinLength(nil, int(info.PieceLength))...)
|
||||
}
|
||||
info = metainfo.Info{Name: name, PieceLength: info.PieceLength, Length: int64(len(data)), Pieces: info.Pieces, MetaVersion: 2, FileTree: metainfo.FileTree{File: metainfo.FileTreeFile{Length: int64(len(data)), PiecesRoot: string(root)}}}
|
||||
layers = map[string]string{string(root): string(pieceHashes)}
|
||||
}
|
||||
// Private torrents avoid public DHT discovery once metadata is available.
|
||||
info.Private = &private
|
||||
raw, err := bencode.Marshal(&info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mi := &metainfo.MetaInfo{InfoBytes: raw, PieceLayers: layers}
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DataDir = source
|
||||
cfg.Seed = true
|
||||
cfg.DisableUTP = true
|
||||
cfg.DisableIPv6 = true
|
||||
cfg.DisableWebtorrent = true
|
||||
cfg.KeepAliveTimeout = time.Minute
|
||||
cfg.MaxAllocPeerRequestDataPerConn = 1 << 20
|
||||
limiter := rate.NewLimiter(rate.Inf, 16*1024)
|
||||
if bytesPerSecond > 0 {
|
||||
limiter.SetLimit(rate.Limit(bytesPerSecond))
|
||||
}
|
||||
cfg.UploadRateLimiter = limiter
|
||||
seed, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { seed.Close() })
|
||||
seeded, _, err := seed.AddTorrentSpec(torrent.TorrentSpecFromMetaInfo(mi))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := seeded.VerifyDataContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-seeded.Complete().On():
|
||||
case <-ctx.Done():
|
||||
t.Fatal("local seeder did not finish verification")
|
||||
}
|
||||
addr := seed.ListenAddrs()[0].String()
|
||||
host, portString, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := net.ResolveTCPAddr("tcp4", net.JoinHostPort(host, portString))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compact := make([]byte, 6)
|
||||
copy(compact, resolved.IP.To4())
|
||||
binary.BigEndian.PutUint16(compact[4:], uint16(resolved.Port))
|
||||
tracker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response, err := bencode.Marshal(map[string]any{"interval": 3600, "peers": string(compact)})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Write(response)
|
||||
}))
|
||||
t.Cleanup(tracker.Close)
|
||||
// The pinned library has a hybrid tracker-dispatch assertion bug.
|
||||
// Hybrid fixtures use their explicit loopback peer rather than a tracker.
|
||||
if !v2 {
|
||||
mi.Announce = tracker.URL
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := mi.Write(&encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
torrentFile := filepath.Join(t.TempDir(), "fixture.torrent")
|
||||
if err := os.WriteFile(torrentFile, encoded.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := mi.MagnetV2()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
magnet := m.String()
|
||||
u, err := url.Parse(magnet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Add("x.pe", addr)
|
||||
u.RawQuery = q.Encode()
|
||||
return &btFixture{MetaInfo: mi, Magnet: u.String(), TorrentFile: torrentFile, DataURI: "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()), TrackerURL: tracker.URL, Files: expected, Seeder: seed, Torrent: seeded, Limiter: limiter}
|
||||
}
|
||||
|
||||
func btPayload(size int, label string) []byte {
|
||||
data := make([]byte, size)
|
||||
for i := range data {
|
||||
data[i] = byte((i*31 + i/251) % 251)
|
||||
}
|
||||
copy(data, label)
|
||||
return data
|
||||
}
|
||||
|
||||
func (f *btFixture) AssertFiles(t testing.TB, dir string, paths ...string) {
|
||||
t.Helper()
|
||||
if len(paths) == 0 {
|
||||
for path := range f.Files {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
for _, path := range paths {
|
||||
got, err := os.ReadFile(filepath.Join(dir, path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, ok := f.Files[path]
|
||||
if !ok {
|
||||
t.Fatalf("unknown fixture file %q", path)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("BT file %s differs: received %d bytes, want %d", path, len(got), len(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *btFixture) FileURI() string {
|
||||
path := filepath.ToSlash(f.TorrentFile)
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return (&url.URL{Scheme: "file", Path: path}).String()
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
func TestStartDoesNotPublishUnverifiedCompletion(t *testing.T) {
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DisableTCP = true
|
||||
cfg.DisableUTP = true
|
||||
c, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
payload := btPayload(32*1024, "unverified")
|
||||
info := metainfo.Info{Name: "payload.bin", Length: int64(len(payload)), PieceLength: 16 * 1024}
|
||||
for offset := 0; offset < len(payload); offset += int(info.PieceLength) {
|
||||
hash := sha1.Sum(payload[offset : offset+int(info.PieceLength)])
|
||||
info.Pieces = append(info.Pieces, hash[:]...)
|
||||
}
|
||||
raw, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &deferredFileStorage{}
|
||||
spec := torrent.TorrentSpecFromMetaInfo(&metainfo.MetaInfo{InfoBytes: raw})
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tor.DisallowDataDownload()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
dir := t.TempDir()
|
||||
payload[20000] ^= 0xff
|
||||
if err := os.WriteFile(filepath.Join(dir, info.Name), payload, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.meta.Opts = &base.Options{Path: dir}
|
||||
// Retain the original event channel to detect even a transient completion.
|
||||
// With no network transports, the damaged piece cannot have been repaired.
|
||||
completed := tor.Complete().On()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-completed:
|
||||
t.Fatal("Start published completion before verifying the existing file")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DataDir = t.TempDir()
|
||||
cfg.DisableTCP = true
|
||||
cfg.DisableUTP = true
|
||||
c, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
store := &deferredFileStorage{}
|
||||
mi, err := metainfo.LoadFromFile("testdata/test.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := torrent.TorrentSpecFromMetaInfo(mi)
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tor.DisallowDataDownload()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
f.meta.Opts = &base.Options{Path: t.TempDir(), SelectFiles: []int{2}}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, file := range tor.Files() {
|
||||
want := torrent.PiecePriorityNone
|
||||
if i == 2 {
|
||||
want = torrent.PiecePriorityNormal
|
||||
}
|
||||
if file.Priority() != want {
|
||||
t.Errorf("file %d priority = %v, want %v", i, file.Priority(), want)
|
||||
}
|
||||
}
|
||||
if len(f.data.Progress) != 1 {
|
||||
t.Fatalf("progress tracks %d files, want 1", len(f.data.Progress))
|
||||
}
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tor.Files()[0].Priority() != torrent.PiecePriorityNone {
|
||||
t.Fatal("resume selected an unselected file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
|
||||
for _, mode := range []string{"partial", "part only", "both paths", "all", "already removed", "remove error"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keepPath := filepath.Join(dir, "bundle", "keep.txt")
|
||||
skipPath := filepath.Join(dir, "bundle", "nested", "skip.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(skipPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keepPath, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(skipPath, []byte("skip"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both files occupy the same piece, so downloading keep.txt also writes skip.txt.
|
||||
hash := sha1.Sum([]byte("keepskip"))
|
||||
info := metainfo.Info{Name: "bundle", PieceLength: 16, Pieces: hash[:], Files: []metainfo.FileInfo{
|
||||
{Length: 4, Path: []string{"keep.txt"}},
|
||||
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
||||
}}
|
||||
storageOpts := storage.NewFileClientOpts{
|
||||
ClientBaseDir: dir,
|
||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||
}
|
||||
// VerifyDataContext can return before storage finishes marking the piece
|
||||
// complete. Disable automatic part-file promotion so it cannot rename
|
||||
// the .part fixtures that this test creates to exercise cleanup.
|
||||
storageOpts.UsePartFiles.Set(false)
|
||||
store := storage.NewFileOpts(storageOpts)
|
||||
defer store.Close()
|
||||
config := torrent.TestingConfig(t)
|
||||
config.DisableTCP = true
|
||||
config.DisableUTP = true
|
||||
c, err := torrent.NewClient(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
spec, err := torrent.TorrentSpecFromMetaInfoErr(&metainfo.MetaInfo{InfoBytes: bencode.MustMarshal(&info)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := tor.VerifyDataContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{Path: dir, SelectFiles: []int{0}}}, data: &fetcherData{}, torrentDropCtx: ctx}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
if mode == "all" {
|
||||
f.meta.Opts.SelectFiles = []int{0, 1}
|
||||
}
|
||||
if !f.isDone() {
|
||||
t.Fatal("selected file is not complete")
|
||||
}
|
||||
if mode == "part only" || mode == "both paths" || mode == "all" {
|
||||
if err := os.WriteFile(skipPath+".part", []byte("skip"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "part only" || mode == "already removed" || mode == "remove error" {
|
||||
if err := os.Remove(skipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "remove error" {
|
||||
if err := os.Mkdir(skipPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skipPath, "child"), []byte("preserve"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err = f.Wait()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
if mode == "remove error" {
|
||||
if err == nil {
|
||||
t.Fatal("cleanup failure was silently ignored")
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(keepPath)
|
||||
if err != nil || string(data) != "keep" {
|
||||
t.Fatalf("selected file changed: %q, %v", data, err)
|
||||
}
|
||||
if mode == "all" {
|
||||
if _, err := os.Stat(skipPath + ".part"); err != nil {
|
||||
t.Fatalf("selected part file was removed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(skipPath)
|
||||
if err != nil || string(data) != "skip" {
|
||||
t.Fatalf("selected file changed: %q, %v", data, err)
|
||||
}
|
||||
} else if mode != "remove error" {
|
||||
for _, removed := range []string{skipPath, skipPath + ".part", filepath.Dir(skipPath)} {
|
||||
if _, err := os.Stat(removed); !os.IsNotExist(err) {
|
||||
t.Fatalf("unselected file or empty directory remains: %s: %v", removed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUnselectedFilePrunesOnlyEmptyParents(t *testing.T) {
|
||||
for _, preserveSibling := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(preserveSibling), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
relative := filepath.Join("bundle", "nested", "deep", "skip.txt")
|
||||
name := filepath.Join(root, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(name+".part", []byte("partial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sibling := filepath.Join(root, "bundle", "keep.txt")
|
||||
if preserveSibling {
|
||||
if err := os.WriteFile(sibling, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := removeUnselectedFile(root, "bundle", relative); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "bundle", "nested")); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty ancestors remain: %v", err)
|
||||
}
|
||||
if preserveSibling {
|
||||
data, err := os.ReadFile(sibling)
|
||||
if err != nil || string(data) != "keep" {
|
||||
t.Fatalf("sibling changed: %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(root, "bundle")); err != nil || !info.IsDir() {
|
||||
t.Fatalf("torrent root directory was removed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Fatalf("download directory was removed: %v", err)
|
||||
}
|
||||
// A file directly inside the torrent root must not cause that root to be pruned.
|
||||
rootFile := filepath.Join("bundle", "root.txt")
|
||||
if err := os.WriteFile(filepath.Join(root, rootFile)+".part", []byte("partial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := removeUnselectedFile(root, "bundle", rootFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "bundle")); err != nil {
|
||||
t.Fatalf("torrent root directory was removed after root-level cleanup: %v", err)
|
||||
}
|
||||
// Repeating cleanup after the file and its subdirectories are gone is harmless.
|
||||
if err := removeUnselectedFile(root, "bundle", relative); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupMatchesNamedAndRootlessStorage(t *testing.T) {
|
||||
for _, version := range []int64{1, 2} {
|
||||
for _, layout := range []string{"rootless", "wrapped", "file-tree-root"} {
|
||||
if version == 1 && layout == "file-tree-root" {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("v%d/%s", version, layout), func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
info := metainfo.Info{Name: metainfo.NoName, PieceLength: 16384}
|
||||
root := dir
|
||||
if layout == "wrapped" {
|
||||
info.Name = "bundle"
|
||||
root = filepath.Join(dir, "bundle")
|
||||
}
|
||||
if version == 1 {
|
||||
info.Files = []metainfo.FileInfo{{Path: []string{"keep.txt"}}, {Path: []string{"nested", "deep", "skip.txt"}}}
|
||||
} else {
|
||||
info.MetaVersion = 2
|
||||
info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{
|
||||
"keep.txt": {},
|
||||
"nested": {Dir: map[string]metainfo.FileTree{"deep": {Dir: map[string]metainfo.FileTree{"skip.txt": {}}}}},
|
||||
}}
|
||||
}
|
||||
if layout == "file-tree-root" {
|
||||
info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{"tree-root": info.FileTree}}
|
||||
root = filepath.Join(dir, "tree-root")
|
||||
}
|
||||
// Keep the library's default FilePathMaker here to independently verify
|
||||
// that cleanup agrees with its real disk layout, including NoName.
|
||||
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
ClientBaseDir: dir,
|
||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||
})
|
||||
defer store.Close()
|
||||
torrentStorage, err := store.OpenTorrent(context.Background(), &info, metainfo.Hash{1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer torrentStorage.Close()
|
||||
skip := filepath.Join(root, "nested", "deep", "skip.txt")
|
||||
if _, err := os.Stat(skip); err != nil {
|
||||
t.Fatalf("unexpected storage layout: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(skip+".part", []byte("partial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := info.UpvertedFiles()
|
||||
boundary, name := torrentFileLayout(&info, files[1])
|
||||
if err := removeUnselectedFile(dir, boundary, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "nested")); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty subtree remains: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "keep.txt")); err != nil {
|
||||
t.Fatalf("selected file removed: %v", err)
|
||||
}
|
||||
// Also verify the boundary when the protected directory becomes empty.
|
||||
if err := os.Remove(filepath.Join(root, "keep.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range files {
|
||||
boundary, name := torrentFileLayout(&info, file)
|
||||
if err := removeUnselectedFile(dir, boundary, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stat, err := os.Stat(root); err != nil || !stat.IsDir() {
|
||||
t.Fatalf("cleanup boundary was removed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
g "github.com/anacrolix/generics"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
// deferredFileStorage lets metadata resolve without opening the user's files.
|
||||
// It binds once. After binding, pieces are supplied directly by file storage.
|
||||
type deferredFileStorage struct {
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
info *metainfo.Info
|
||||
hash metainfo.Hash
|
||||
knownHashes []bool
|
||||
owner storage.ClientImplCloser
|
||||
torrent storage.TorrentImpl
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *deferredFileStorage) OpenTorrent(ctx context.Context, info *metainfo.Info, hash metainfo.Hash) (storage.TorrentImpl, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed || s.info != nil {
|
||||
return storage.TorrentImpl{}, errors.New("torrent storage already opened or closed")
|
||||
}
|
||||
s.ctx, s.info, s.hash = ctx, info, hash
|
||||
s.knownHashes = make([]bool, info.NumPieces())
|
||||
return storage.TorrentImpl{
|
||||
PieceWithHash: func(p metainfo.Piece, hash g.Option[[]byte]) storage.PieceImpl {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return metadataPiece{err: os.ErrClosed}
|
||||
}
|
||||
if s.owner == nil {
|
||||
s.knownHashes[p.Index()] = hash.Ok
|
||||
// A missing v2 hash must remain unknown until its arrival changes
|
||||
// completion and updates the library's piece request priorities.
|
||||
return metadataPiece{known: hash.Ok}
|
||||
}
|
||||
if s.torrent.PieceWithHash != nil {
|
||||
return s.torrent.PieceWithHash(p, hash)
|
||||
}
|
||||
return s.torrent.Piece(p)
|
||||
},
|
||||
Close: s.Close,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *deferredFileStorage) bind(opts storage.NewFileClientOpts) ([]bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
if s.info == nil {
|
||||
return nil, errors.New("torrent metadata is not ready")
|
||||
}
|
||||
if s.owner != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if opts.FilePathMaker == nil {
|
||||
opts.FilePathMaker = func(o storage.FilePathMakerOpts) string { _, name := torrentFileLayout(o.Info, *o.File); return name }
|
||||
}
|
||||
owner := storage.NewFileOpts(opts)
|
||||
t, err := owner.OpenTorrent(s.ctx, s.info, s.hash)
|
||||
if err != nil {
|
||||
owner.Close()
|
||||
return nil, err
|
||||
}
|
||||
s.owner, s.torrent = owner, t
|
||||
return append([]bool(nil), s.knownHashes...), nil
|
||||
}
|
||||
|
||||
func (s *deferredFileStorage) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
if s.owner == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
if s.torrent.Close != nil {
|
||||
err = s.torrent.Close()
|
||||
}
|
||||
return errors.Join(err, s.owner.Close())
|
||||
}
|
||||
|
||||
type metadataPiece struct {
|
||||
known bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (p metadataPiece) Completion() storage.Completion {
|
||||
return storage.Completion{Ok: p.known, Err: p.err}
|
||||
}
|
||||
func (p metadataPiece) ReadAt([]byte, int64) (int, error) {
|
||||
return 0, errors.New("download storage is not initialized")
|
||||
}
|
||||
func (p metadataPiece) WriteAt([]byte, int64) (int, error) {
|
||||
return 0, errors.New("download storage is not initialized")
|
||||
}
|
||||
func (p metadataPiece) MarkComplete() error { return errors.New("download storage is not initialized") }
|
||||
func (p metadataPiece) MarkNotComplete() error {
|
||||
return errors.New("download storage is not initialized")
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
var defaultTrackerSubscribeURLs = []string{
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt",
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt",
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt",
|
||||
}
|
||||
|
||||
func validTracker(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" || u.User != nil {
|
||||
return false
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "udp", "http", "https", "ws", "wss":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func uniqueTrackers(groups ...[]string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, group := range groups {
|
||||
for _, raw := range group {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// effectiveTrackers derives the client list without persisting duplicate lists.
|
||||
func (c *config) effectiveTrackers() []string {
|
||||
groups := [][]string{c.CustomTrackers}
|
||||
for _, source := range c.TrackerSubscribeURLs {
|
||||
groups = append(groups, c.State.TrackerSources[source])
|
||||
}
|
||||
return uniqueTrackers(groups...)
|
||||
}
|
||||
func (fm *FetcherManager) NormalizeConfig(raw json.RawMessage) (any, error) {
|
||||
c := fm.DefaultConfig().(*config)
|
||||
if err := json.Unmarshal(raw, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.ListenPort < 0 || c.ListenPort > 65535 || c.SeedRatio < 0 || c.SeedTime < 0 {
|
||||
return nil, errors.New("invalid BT configuration")
|
||||
}
|
||||
if len(c.TrackerSubscribeURLs) > 64 {
|
||||
return nil, errors.New("too many tracker subscriptions")
|
||||
}
|
||||
c.TrackerSubscribeURLs = uniqueTrackers(c.TrackerSubscribeURLs)
|
||||
for _, source := range c.TrackerSubscribeURLs {
|
||||
u, err := url.Parse(source)
|
||||
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil {
|
||||
return nil, errors.New("invalid tracker subscription URL")
|
||||
}
|
||||
}
|
||||
c.CustomTrackers = uniqueTrackers(c.CustomTrackers)
|
||||
for _, tracker := range c.CustomTrackers {
|
||||
if !validTracker(tracker) {
|
||||
return nil, fmt.Errorf("invalid tracker URL: %s", tracker)
|
||||
}
|
||||
}
|
||||
if c.State.TrackerSources == nil {
|
||||
c.State.TrackerSources = map[string][]string{}
|
||||
}
|
||||
for source := range c.State.TrackerSources {
|
||||
if !slices.Contains(c.TrackerSubscribeURLs, source) {
|
||||
delete(c.State.TrackerSources, source)
|
||||
}
|
||||
}
|
||||
if c.State.TrackerSubscriptionStatuses == nil {
|
||||
c.State.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
|
||||
}
|
||||
for source := range c.State.TrackerSubscriptionStatuses {
|
||||
if !slices.Contains(c.TrackerSubscribeURLs, source) {
|
||||
delete(c.State.TrackerSubscriptionStatuses, source)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func (fm *FetcherManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
|
||||
return []fetcher.CronJob{{
|
||||
Name: "update-trackers",
|
||||
Every: 24 * time.Hour,
|
||||
Timeout: 90 * time.Second,
|
||||
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
|
||||
return fm.updateTrackers(ctx, host, source)
|
||||
},
|
||||
}}
|
||||
}
|
||||
func parseTrackers(data []byte) ([]string, error) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
scanner.Buffer(make([]byte, 4096), 64*1024)
|
||||
result := []string{}
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff"))
|
||||
if validTracker(line) {
|
||||
result = append(result, line)
|
||||
}
|
||||
if len(result) > 20000 {
|
||||
return nil, errors.New("too many trackers")
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("subscription contains no valid trackers")
|
||||
}
|
||||
return uniqueTrackers(result), nil
|
||||
}
|
||||
func (fm *FetcherManager) updateTrackers(ctx context.Context, host fetcher.ProtocolHost, source fetcher.TriggerSource) error {
|
||||
snapshot, err := fetcher.ReadConfig[config](host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (source == fetcher.TriggerScheduled && !snapshot.AutoUpdateTrackers) || len(snapshot.TrackerSubscribeURLs) == 0 {
|
||||
return fetcher.ErrJobSkipped
|
||||
}
|
||||
fetched := map[string][]string{}
|
||||
statuses := map[string]trackerSubscriptionStatus{}
|
||||
var failures []error
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
slots := make(chan struct{}, 4)
|
||||
for _, address := range snapshot.TrackerSubscribeURLs {
|
||||
wg.Add(1)
|
||||
go func(address string) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
data, err := host.Fetch(ctx, address, 2<<20, func(body []byte) error { _, err := parseTrackers(body); return err })
|
||||
var trackers []string
|
||||
if err == nil {
|
||||
trackers, err = parseTrackers(data)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
statuses[address] = trackerSubscriptionStatus{Success: err == nil, TrackerCount: len(trackers), LastFetchTime: time.Now().UTC()}
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Errorf("subscription %s: %w", address, err))
|
||||
} else {
|
||||
fetched[address] = trackers
|
||||
}
|
||||
}(address)
|
||||
}
|
||||
wg.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
err = fetcher.UpdateState[config, state](host, func(current *config, state *state) error {
|
||||
if !slices.Equal(current.TrackerSubscribeURLs, snapshot.TrackerSubscribeURLs) {
|
||||
return fetcher.ErrConfigChanged
|
||||
}
|
||||
if state.TrackerSources == nil {
|
||||
state.TrackerSources = map[string][]string{}
|
||||
}
|
||||
if state.TrackerSubscriptionStatuses == nil {
|
||||
state.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
|
||||
}
|
||||
for address, status := range statuses {
|
||||
state.TrackerSubscriptionStatuses[address] = status
|
||||
}
|
||||
for address, trackers := range fetched {
|
||||
state.TrackerSources[address] = trackers
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
state.LastTrackerUpdateTime = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := fetcher.ReadConfig[config](host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fm.applyTrackers(current.effectiveTrackers())
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
func (fm *FetcherManager) applyTrackers(trackers []string) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
announce := make([][]string, 0, len(trackers))
|
||||
for _, tracker := range trackers {
|
||||
announce = append(announce, []string{tracker})
|
||||
}
|
||||
for _, t := range client.Torrents() {
|
||||
info := t.Info()
|
||||
if info == nil || (info.Private != nil && *info.Private) {
|
||||
continue
|
||||
}
|
||||
t.AddTrackers(announce)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
func trackerTestHost(t *testing.T, c *config, fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)) (fetcher.ProtocolHost, *sync.Mutex) {
|
||||
t.Helper()
|
||||
mu := &sync.Mutex{}
|
||||
return fetcher.ProtocolHost{
|
||||
Read: func(dst any) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dst)
|
||||
},
|
||||
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
raw, _ := json.Marshal(c)
|
||||
currentState, _ := json.Marshal(c.State)
|
||||
next, err := change(raw, currentState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err = json.Marshal(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, &c.State)
|
||||
},
|
||||
Fetch: fetch,
|
||||
}, mu
|
||||
}
|
||||
func TestTrackerRefreshPartialSuccessUsesLatestConfig(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
cfg.TrackerSubscribeURLs = []string{"https://a.example/list", "https://b.example/list"}
|
||||
cfg.CustomTrackers = []string{"udp://custom.example:80/announce"}
|
||||
cfg.State.TrackerSources = map[string][]string{cfg.TrackerSubscribeURLs[0]: {"udp://old.example:80/announce"}, cfg.TrackerSubscribeURLs[1]: {"udp://cached.example:80/announce"}}
|
||||
host, mu := trackerTestHost(t, cfg, nil)
|
||||
host.Fetch = func(ctx context.Context, address string, _ int64, validate func([]byte) error) ([]byte, error) {
|
||||
if address == "https://b.example/list" {
|
||||
return nil, errors.New("offline")
|
||||
}
|
||||
// Simulate a frontend update while the network request is in flight.
|
||||
mu.Lock()
|
||||
cfg.CustomTrackers = []string{"udp://new-custom.example:80/announce"}
|
||||
mu.Unlock()
|
||||
data := []byte("udp://fresh.example:80/announce\n# ignored\nudp://fresh.example:80/announce\n")
|
||||
return data, validate(data)
|
||||
}
|
||||
err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual)
|
||||
if err == nil {
|
||||
t.Fatal("partial failure must be reported")
|
||||
}
|
||||
want := []string{"udp://new-custom.example:80/announce", "udp://fresh.example:80/announce", "udp://cached.example:80/announce"}
|
||||
if !reflect.DeepEqual(cfg.effectiveTrackers(), want) {
|
||||
t.Fatalf("got %v", cfg.effectiveTrackers())
|
||||
}
|
||||
success := cfg.State.TrackerSubscriptionStatuses["https://a.example/list"]
|
||||
failure := cfg.State.TrackerSubscriptionStatuses["https://b.example/list"]
|
||||
if !success.Success || success.TrackerCount != 1 || success.LastFetchTime.IsZero() || failure.Success || failure.TrackerCount != 0 || failure.LastFetchTime.IsZero() {
|
||||
t.Fatalf("unexpected subscription statuses: %+v", cfg.State.TrackerSubscriptionStatuses)
|
||||
}
|
||||
if !cfg.State.LastTrackerUpdateTime.IsZero() {
|
||||
t.Fatal("partial refresh marked fully successful")
|
||||
}
|
||||
}
|
||||
func TestTrackerRefreshDiscardsChangedSubscriptions(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
|
||||
host, mu := trackerTestHost(t, cfg, nil)
|
||||
host.Fetch = func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
mu.Lock()
|
||||
cfg.TrackerSubscribeURLs = []string{}
|
||||
mu.Unlock()
|
||||
return []byte("udp://fresh.example:80/announce"), nil
|
||||
}
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); !errors.Is(err, fetcher.ErrConfigChanged) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.State.TrackerSources) != 0 {
|
||||
t.Fatal("outdated source was committed")
|
||||
}
|
||||
}
|
||||
func TestTrackerRefreshManualWorksWhenAutomaticDisabled(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
cfg.AutoUpdateTrackers = false
|
||||
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
|
||||
calls := 0
|
||||
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
calls++
|
||||
return []byte("udp://fresh.example:80/announce"), nil
|
||||
})
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerScheduled); !errors.Is(err, fetcher.ErrJobSkipped) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatal("scheduled job fetched while disabled")
|
||||
}
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || cfg.State.LastTrackerUpdateTime.IsZero() {
|
||||
t.Fatal("manual refresh did not succeed")
|
||||
}
|
||||
}
|
||||
func TestTrackerConfigPreservesExplicitSettings(t *testing.T) {
|
||||
value, err := (&FetcherManager{}).NormalizeConfig(json.RawMessage(`{
|
||||
"listenPort":6881, "trackerSubscribeUrls":[], "autoUpdateTrackers":false,
|
||||
"customTrackers":["udp://custom.example:80/announce"]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := value.(*config)
|
||||
if cfg.AutoUpdateTrackers || len(cfg.TrackerSubscribeURLs) != 0 || cfg.ListenPort != 6881 {
|
||||
t.Fatalf("%+v", cfg)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.CustomTrackers) {
|
||||
t.Fatalf("derived trackers: %v", cfg.effectiveTrackers())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrackerList(t *testing.T) {
|
||||
got, err := parseTrackers([]byte("\ufeffudp://tracker.example:80/announce\r\n\n<html>error</html>\nhttps://tracker.example/announce\n"))
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if _, err := parseTrackers([]byte("<html>mirror error</html>")); err == nil {
|
||||
t.Fatal("invalid mirror body accepted")
|
||||
}
|
||||
}
|
||||
func TestTrackerDefaultsRegisterDailyJob(t *testing.T) {
|
||||
jobs := (&FetcherManager{}).CronJobs(fetcher.ProtocolHost{})
|
||||
if len(jobs) != 1 || jobs[0].Name != "update-trackers" || jobs[0].Every != 24*time.Hour {
|
||||
t.Fatal(jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerRefreshAllFailuresPersistStatusesAndKeepCache(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
address := "https://custom.example/trackers.txt"
|
||||
cfg.TrackerSubscribeURLs = []string{address}
|
||||
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
|
||||
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now().Add(-time.Hour)}
|
||||
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
return nil, errors.New("offline")
|
||||
})
|
||||
before := time.Now()
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err == nil {
|
||||
t.Fatal("failure was not reported")
|
||||
}
|
||||
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
|
||||
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.Before(before) {
|
||||
t.Fatalf("failed fetch status not stored: %+v", status)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.State.TrackerSources[address], []string{"udp://cached.example:80/announce"}) {
|
||||
t.Fatal("last good cache lost")
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.State.TrackerSources[address]) {
|
||||
t.Fatal("cached trackers not retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerConfigPrunesRemovedSubscriptions(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
address := "https://custom.example/trackers.txt"
|
||||
cfg.TrackerSubscribeURLs = []string{}
|
||||
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
|
||||
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now()}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
value, err := fm.NormalizeConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := value.(*config)
|
||||
if len(next.State.TrackerSources) != 0 || len(next.State.TrackerSubscriptionStatuses) != 0 || len(next.effectiveTrackers()) != 0 {
|
||||
t.Fatal("removed subscription still owns cached results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerRefreshTimeoutPersistsCompletedAttemptStatus(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
address := "https://custom.example/trackers.txt"
|
||||
cfg.TrackerSubscribeURLs = []string{address}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
cancel()
|
||||
return nil, context.DeadlineExceeded
|
||||
})
|
||||
if err := fm.updateTrackers(ctx, host, fetcher.TriggerManual); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
|
||||
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.IsZero() {
|
||||
t.Fatalf("timeout status lost: %+v", status)
|
||||
}
|
||||
}
|
||||
@@ -67,17 +67,14 @@ func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
f.ctl.GetConfig(&f.config)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
link, err := parseLink(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
|
||||
f.meta.Res = buildResource(link)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
f := (&FetcherManager{}).Build()
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
err := f.Resolve(&base.Request{URL: testLink})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||
)
|
||||
|
||||
const (
|
||||
connectTimeout = 15 * time.Second
|
||||
readTimeout = 15 * time.Second
|
||||
quitTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// target is a parsed FTP URL. It holds the password, so it must never be
|
||||
// formatted into an error or a log line.
|
||||
type target struct {
|
||||
host string // host name or IP, the TLS server name
|
||||
addr string // host:port
|
||||
user string
|
||||
pass string
|
||||
path string // path relative to the login directory, "" for the directory itself
|
||||
tlsMode string
|
||||
}
|
||||
|
||||
// parseTarget reads an ftp, ftps or ftpes URL. The path follows RFC 1738: it
|
||||
// is relative to the login directory, and an encoded leading slash
|
||||
// (ftp://host/%2Fpub) makes it absolute.
|
||||
func parseTarget(rawURL, optTLS string) (*target, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// url.Error repeats the whole URL, password included.
|
||||
return nil, errors.New("ftp: invalid URL")
|
||||
}
|
||||
t := &target{}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "ftps":
|
||||
t.tlsMode = pftp.TLSImplicit
|
||||
case "ftpes":
|
||||
t.tlsMode = pftp.TLSExplicit
|
||||
case "ftp":
|
||||
switch strings.ToLower(optTLS) {
|
||||
case "", pftp.TLSNone:
|
||||
t.tlsMode = pftp.TLSNone
|
||||
case pftp.TLSExplicit:
|
||||
t.tlsMode = pftp.TLSExplicit
|
||||
case pftp.TLSImplicit:
|
||||
t.tlsMode = pftp.TLSImplicit
|
||||
default:
|
||||
return nil, fmt.Errorf("ftp: unknown tls mode %q", optTLS)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("ftp: unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
t.host = u.Hostname()
|
||||
if t.host == "" {
|
||||
return nil, errors.New("ftp: URL has no host")
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = "21"
|
||||
if t.tlsMode == pftp.TLSImplicit {
|
||||
port = "990"
|
||||
}
|
||||
}
|
||||
t.addr = net.JoinHostPort(t.host, port)
|
||||
if u.User != nil {
|
||||
// Username and Password are already percent-decoded.
|
||||
t.user = u.User.Username()
|
||||
t.pass, _ = u.User.Password()
|
||||
}
|
||||
if t.user == "" {
|
||||
t.user, t.pass = "anonymous", "anonymous@"
|
||||
}
|
||||
t.path = strings.TrimPrefix(u.Path, "/")
|
||||
return t, nil
|
||||
}
|
||||
|
||||
type dialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// session is one logged-in control connection. It owns the network
|
||||
// connections it opens, so abort can unblock any read or write in progress.
|
||||
type session struct {
|
||||
*ftp.ServerConn
|
||||
|
||||
ctrl net.Conn // control connection as dialed, under any TLS layer
|
||||
dataHost string // where data connections go
|
||||
|
||||
mu sync.Mutex
|
||||
data net.Conn // current data connection as dialed
|
||||
closed bool
|
||||
stop func() bool
|
||||
}
|
||||
|
||||
// deadline bounds the next control-connection exchange. The control
|
||||
// connection is idle during a transfer, so every exchange sets a new one.
|
||||
func (s *session) deadline() {
|
||||
_ = s.ctrl.SetDeadline(time.Now().Add(readTimeout))
|
||||
}
|
||||
|
||||
// clearDeadline lets the control connection stay idle through a transfer.
|
||||
func (s *session) clearDeadline() {
|
||||
_ = s.ctrl.SetDeadline(time.Time{})
|
||||
}
|
||||
|
||||
// abort closes every connection at once. Pending calls fail immediately.
|
||||
func (s *session) abort() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed = true
|
||||
_ = s.ctrl.Close()
|
||||
if s.data != nil {
|
||||
_ = s.data.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// quit logs out and waits, briefly, for the server to hang up. Servers that
|
||||
// cap logins count a session until they have seen it end, so the next login
|
||||
// would otherwise be refused.
|
||||
func (s *session) quit() {
|
||||
if s.stop != nil {
|
||||
s.stop()
|
||||
}
|
||||
_ = s.ctrl.SetDeadline(time.Now().Add(quitTimeout))
|
||||
if s.ServerConn != nil {
|
||||
_ = s.ServerConn.Quit()
|
||||
}
|
||||
s.abort()
|
||||
}
|
||||
|
||||
func (s *session) setData(c net.Conn) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
_ = c.Close()
|
||||
return net.ErrClosed
|
||||
}
|
||||
s.data = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// ctrlConn is the control connection handed to the ftp library.
|
||||
type ctrlConn struct {
|
||||
net.Conn
|
||||
}
|
||||
|
||||
// RemoteAddr must be a *net.TCPAddr: the library asserts it. A connection
|
||||
// through a proxy may report something else, and its address is not used
|
||||
// for data connections anyway (see dialData).
|
||||
func (c ctrlConn) RemoteAddr() net.Addr {
|
||||
if a, ok := c.Conn.RemoteAddr().(*net.TCPAddr); ok {
|
||||
return a
|
||||
}
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
|
||||
// Close is called by the library's Quit right after it sends QUIT. Reading
|
||||
// until the server hangs up (bounded by the deadline quit set) tells us the
|
||||
// server has ended the session.
|
||||
func (c ctrlConn) Close() error {
|
||||
_, _ = io.Copy(io.Discard, c.Conn)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// dial opens a control connection, upgrades it to TLS when asked, and logs in.
|
||||
//
|
||||
// The library is given a dial function for every connection. That is how
|
||||
// data connections go through the same proxy as the control connection, but
|
||||
// it also means the library does no TLS on them: with a dial function it
|
||||
// neither wraps an implicit-TLS control connection nor any data connection.
|
||||
// We do both here. Explicit TLS (AUTH TLS) is still done by the library, and
|
||||
// so are PBSZ and PROT P after login. For the same reason the library's
|
||||
// DialWithTimeout and DialWithContext would have no effect: connectTimeout
|
||||
// and ctx are applied to our own dials instead.
|
||||
func (f *Fetcher) dial(ctx context.Context, t *target) (*session, error) {
|
||||
netDial, proxied, err := f.netDialer(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tlsConf *tls.Config
|
||||
if t.tlsMode != pftp.TLSNone {
|
||||
tlsConf = f.tlsConfig(t.host)
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
||||
defer cancel()
|
||||
raw, err := netDial(dialCtx, "tcp", t.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctrl := raw
|
||||
if t.tlsMode == pftp.TLSImplicit {
|
||||
tc := tls.Client(raw, tlsConf)
|
||||
if err := tc.HandshakeContext(dialCtx); err != nil {
|
||||
_ = raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
ctrl = tc
|
||||
}
|
||||
|
||||
s := &session{ctrl: raw, dataHost: t.host}
|
||||
if a, ok := raw.RemoteAddr().(*net.TCPAddr); ok && !proxied {
|
||||
// Same IP as the control connection, as the library itself does,
|
||||
// so a name with several addresses cannot send us elsewhere.
|
||||
s.dataHost = a.IP.String()
|
||||
}
|
||||
s.stop = context.AfterFunc(ctx, s.abort)
|
||||
s.deadline()
|
||||
|
||||
handedOut := false
|
||||
opts := []ftp.DialOption{
|
||||
ftp.DialWithShutTimeout(readTimeout),
|
||||
ftp.DialWithDialFunc(func(network, addr string) (net.Conn, error) {
|
||||
if !handedOut {
|
||||
// Dial asks for the control connection first, once.
|
||||
handedOut = true
|
||||
return ctrlConn{ctrl}, nil
|
||||
}
|
||||
return s.dialData(ctx, netDial, tlsConf, addr)
|
||||
}),
|
||||
}
|
||||
switch t.tlsMode {
|
||||
case pftp.TLSImplicit:
|
||||
opts = append(opts, ftp.DialWithTLS(tlsConf))
|
||||
case pftp.TLSExplicit:
|
||||
opts = append(opts, ftp.DialWithExplicitTLS(tlsConf))
|
||||
}
|
||||
c, err := ftp.Dial(t.addr, opts...)
|
||||
if err != nil {
|
||||
s.stop()
|
||||
s.abort()
|
||||
return nil, err
|
||||
}
|
||||
s.ServerConn = c
|
||||
s.deadline()
|
||||
if err := c.Login(t.user, t.pass); err != nil {
|
||||
s.stop()
|
||||
s.abort()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// dialData opens a passive data connection. The library takes the host from
|
||||
// the control connection's peer, which through a proxy is the proxy, so only
|
||||
// the port is used here.
|
||||
func (s *session) dialData(ctx context.Context, netDial dialContextFunc, tlsConf *tls.Config, addr string) (net.Conn, error) {
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
||||
defer cancel()
|
||||
raw, err := netDial(dialCtx, "tcp", net.JoinHostPort(s.dataHost, port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.setData(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tlsConf != nil {
|
||||
// The handshake runs on the first read, as in the library, because
|
||||
// some servers only start it once the transfer command is answered.
|
||||
return tls.Client(raw, tlsConf), nil
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// tlsConfig returns the TLS settings for one control connection and its data
|
||||
// connections. Certificates are verified unless the request sets
|
||||
// skipVerifyCert, as for HTTP.
|
||||
//
|
||||
// Each call gets its own one-entry session cache, so a data connection
|
||||
// resumes the TLS session of its own control connection. vsftpd
|
||||
// (require_ssl_reuse) only checks that some session was resumed, but ProFTPD's
|
||||
// mod_tls also checks that it is the control connection's; a cache shared by
|
||||
// several workers would hand a data connection another worker's session.
|
||||
// A cache in the manager's base config is kept as given.
|
||||
func (f *Fetcher) tlsConfig(host string) *tls.Config {
|
||||
cfg := &tls.Config{}
|
||||
if f.manager != nil && f.manager.baseTLS != nil {
|
||||
cfg = f.manager.baseTLS.Clone()
|
||||
}
|
||||
cfg.ServerName = host
|
||||
if f.meta.Req != nil && f.meta.Req.SkipVerifyCert {
|
||||
cfg.InsecureSkipVerify = true
|
||||
}
|
||||
if cfg.ClientSessionCache == nil {
|
||||
cfg.ClientSessionCache = tls.NewLRUClientSessionCache(1)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// netDialer picks how to reach the server, from the same proxy settings the
|
||||
// HTTP protocol reads (the task's proxy, otherwise the global one).
|
||||
func (f *Fetcher) netDialer(t *target) (dialContextFunc, bool, error) {
|
||||
direct := (&net.Dialer{Timeout: connectTimeout}).DialContext
|
||||
pu, err := f.proxyURL(t)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if pu == nil {
|
||||
return direct, false, nil
|
||||
}
|
||||
switch strings.ToLower(pu.Scheme) {
|
||||
case "socks5", "socks5h":
|
||||
d, err := proxy.FromURL(pu, &net.Dialer{Timeout: connectTimeout})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("ftp: socks5 proxy: %w", err)
|
||||
}
|
||||
if cd, ok := d.(proxy.ContextDialer); ok {
|
||||
return cd.DialContext, true, nil
|
||||
}
|
||||
return func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||
return d.Dial(network, addr)
|
||||
}, true, nil
|
||||
default:
|
||||
// An HTTP proxy is not used. Every passive data connection goes to
|
||||
// a port the server picks, and HTTP proxies usually allow CONNECT to
|
||||
// port 443 only, so the transfer would fail after the login worked.
|
||||
f.warnOnce.Do(func() {
|
||||
if l := f.logger(); l != nil {
|
||||
l.Warn().Msgf("ftp: the %s proxy at %s cannot carry FTP data connections (HTTP proxy), connecting directly; use a SOCKS5 proxy for FTP", strings.ToLower(pu.Scheme), pu.Host)
|
||||
}
|
||||
})
|
||||
return direct, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) proxyURL(t *target) (*url.URL, error) {
|
||||
if f.ctl == nil || f.ctl.GetProxy == nil {
|
||||
return nil, nil
|
||||
}
|
||||
handler := f.ctl.GetProxy(f.meta.Req.Proxy)
|
||||
if handler == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// The proxy chooser reads only the URL; the credentials stay out.
|
||||
req := &http.Request{URL: &url.URL{Scheme: "ftp", Host: t.addr, Path: "/" + t.path}, Header: http.Header{}}
|
||||
pu, err := handler(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("ftp: cannot read the proxy settings")
|
||||
}
|
||||
return pu, nil
|
||||
}
|
||||
|
||||
// isLoginRefusal tells a server that is full apart from bad credentials.
|
||||
// 421 always means "not now". 530 means a refused login; it is only a
|
||||
// capacity limit when these credentials have worked before. The library
|
||||
// drops the code when USER itself is refused, so a "too many" message counts
|
||||
// as well.
|
||||
func isLoginRefusal(err error, credentialsWorked bool) bool {
|
||||
var te *textproto.Error
|
||||
if errors.As(err, &te) {
|
||||
switch te.Code {
|
||||
case ftp.StatusNotAvailable:
|
||||
return true
|
||||
case ftp.StatusNotLoggedIn:
|
||||
return credentialsWorked || strings.Contains(strings.ToLower(te.Msg), "too many")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "too many")
|
||||
}
|
||||
|
||||
// isPermanent reports errors a retry cannot fix: a 5xx reply and a
|
||||
// certificate that does not verify.
|
||||
func isPermanent(err error) bool {
|
||||
var te *textproto.Error
|
||||
if errors.As(err, &te) {
|
||||
return te.Code >= 500
|
||||
}
|
||||
var verifyErr *tls.CertificateVerificationError
|
||||
if errors.As(err, &verifyErr) {
|
||||
return true
|
||||
}
|
||||
var recordErr tls.RecordHeaderError
|
||||
if errors.As(err, &recordErr) {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, errSizeChanged)
|
||||
}
|
||||
|
||||
func replyCode(err error) int {
|
||||
var te *textproto.Error
|
||||
if errors.As(err, &te) {
|
||||
return te.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ftp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
iftp "github.com/GopeedLab/gopeed/internal/protocol/ftp"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||
)
|
||||
|
||||
// syncBuffer is a log sink that the downloader's goroutines can share.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// taskEvents records the first done or error event of every task, so the
|
||||
// test never reads a task's status while the downloader writes it.
|
||||
type taskEvents struct {
|
||||
mu sync.Mutex
|
||||
final map[string]download.EventKey
|
||||
}
|
||||
|
||||
func (e *taskEvents) listen(ev *download.Event) {
|
||||
if ev.Key != download.EventKeyDone && ev.Key != download.EventKeyError {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if _, seen := e.final[ev.Task.ID]; !seen {
|
||||
e.final[ev.Task.ID] = ev.Key
|
||||
}
|
||||
}
|
||||
|
||||
func (e *taskEvents) wait(t *testing.T, id string) download.EventKey {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
e.mu.Lock()
|
||||
key, ok := e.final[id]
|
||||
e.mu.Unlock()
|
||||
if ok {
|
||||
return key
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("task %s did not finish", id)
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestPasswordNeverLogged runs FTP tasks through the downloader with its core
|
||||
// logger captured: a success, a missing file and a wrong password, all behind
|
||||
// an HTTP proxy that FTP must skip with a warning.
|
||||
func TestPasswordNeverLogged(t *testing.T) {
|
||||
srv := iftp.StartFTPServer(t, iftp.ServerOptions{User: "u", Pass: "p@ss"})
|
||||
want := srv.WriteFile(t, "secret.bin", iftp.RandomBytes(300000, 21))
|
||||
|
||||
d := download.NewDownloader(&download.DownloaderConfig{Storage: download.NewMemStorage(), StorageDir: t.TempDir()})
|
||||
logs := &syncBuffer{}
|
||||
d.Logger = &logger.Logger{Logger: zerolog.New(logs)}
|
||||
events := &taskEvents{final: map[string]download.EventKey{}}
|
||||
d.Listener(events.listen)
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Close, not Clear: Clear resets the extension list without a lock while
|
||||
// the last task's done hooks may still read it.
|
||||
defer d.Close()
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: "127.0.0.1:1", Usr: "proxyuser", Pwd: "proxysecret"}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
create := func(rawURL string) string {
|
||||
id, err := d.CreateDirect(&base.Request{URL: rawURL}, &base.Options{Path: dir, Extra: &pftp.OptsExtra{Connections: 2}})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "p@ss") || strings.Contains(err.Error(), "p%40ss") {
|
||||
t.Fatalf("the password leaked into an error: %v", err)
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
root := "ftp://u:p%40ss@" + srv.Addr + "/"
|
||||
|
||||
okID := create(root + "secret.bin")
|
||||
if key := events.wait(t, okID); key != download.EventKeyDone {
|
||||
t.Fatalf("download ended with %q, logs:\n%s", key, logs.String())
|
||||
}
|
||||
ok := d.GetTask(okID)
|
||||
if ok.Protocol != "ftp" {
|
||||
t.Fatalf("protocol %q, want ftp", ok.Protocol)
|
||||
}
|
||||
if got := iftp.FileSHA256(t, ok.Meta.SingleFilepath()); got != want {
|
||||
t.Fatalf("sha256 %s, want %s", got, want)
|
||||
}
|
||||
missingID := create(root + "missing.bin")
|
||||
if key := events.wait(t, missingID); key != download.EventKeyError {
|
||||
t.Fatalf("a missing file must fail, got %q", key)
|
||||
}
|
||||
wrongID := create("ftp://u:wr0ng%40pw@" + srv.Addr + "/secret.bin")
|
||||
if key := events.wait(t, wrongID); key != download.EventKeyError {
|
||||
t.Fatalf("a wrong password must fail, got %q", key)
|
||||
}
|
||||
missing, wrong := d.GetTask(missingID), d.GetTask(wrongID)
|
||||
for _, task := range []*download.Task{ok, missing, wrong} {
|
||||
if name := task.Name(); strings.Contains(name, "p@ss") || strings.Contains(name, "p%40ss") || strings.Contains(name, "wr0ng") {
|
||||
t.Fatalf("task name %q shows the password", name)
|
||||
}
|
||||
}
|
||||
|
||||
out := logs.String()
|
||||
if !strings.Contains(out, "task id") || !strings.Contains(out, "HTTP proxy") {
|
||||
t.Fatalf("the captured log misses the expected lines, so it proves nothing:\n%s", out)
|
||||
}
|
||||
for _, secret := range []string{"p@ss", "p%40ss", "wr0ng", "proxysecret"} {
|
||||
if strings.Contains(out, secret) {
|
||||
t.Fatalf("%q appears in the log:\n%s", secret, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 50 of 502 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user