Compare commits

..
Author SHA1 Message Date
Levi af40e8aa63 release: v1.8.3 2025-11-30 21:11:04 +08:00
Levi 64c5ece02c docs: add WeChat Official Account qr (#1192) 2025-11-29 13:19:37 +08:00
Levi 4ccbc3ebc5 feat: support custom GitHub mirror config (#1191) 2025-11-29 12:39:24 +08:00
BerryMC 14d9642d3c feat: add RPM package build (#1190) 2025-11-29 11:35:32 +08:00
Copilot 2f5fbfb17c fix: HTTP filename parsing for mixed encodings and GBK-encoded (#1189) 2025-11-29 11:34:50 +08:00
Copilot 49b4d99b5e feat: add download directory placeholders, category support (#1188) 2025-11-27 18:39:08 +08:00
Copilot 9d9f1caaaa feat: support webhook (#1187) 2025-11-25 23:34:45 +08:00
Copilot 5bb8541385 fix: proxy username and password not persisting in settings (#1183) 2025-11-24 19:29:49 +08:00
Levi f7189668fd ci: pin windows runner image to windows-2022 (#1184) 2025-11-24 18:32:56 +08:00
Little-King 1b36ed1807 fix: defect in extension process during rename duplicate file (#1158) 2025-11-14 18:49:21 +08:00
Levi 07dc196c3c feat: add support for Waterfox browser detection (#1146) 2025-10-15 20:44:41 +08:00
Levi 76bec67ab2 ci: fix windows runner 2025-10-12 22:45:43 +08:00
Levi a2a9e3dc2a release: v1.8.2 2025-10-12 21:17:40 +08:00
Levi 9a5680fb45 feat: refactor browser extension communication to use IPC (#1130) 2025-10-12 21:17:07 +08:00
Levi 75a8a3a9ac ci: downgrade to Ubuntu 22.04 (#1128) 2025-10-09 21:01:06 +08:00
Levi 3e8c85b945 feat: update GitHub mirrors (#1137) 2025-10-09 20:59:56 +08:00
Levi 90f70d1bf6 feat: isolate TLS fingerprint for each extension lifecycle (#1135) 2025-10-09 20:37:21 +08:00
Levi 20a70d7f91 fix: certain special single-file torrent downloads (#1136) 2025-09-29 22:52:06 +08:00
Levi d33546a4b5 fix: update deleteAll method to accept force parameter (#1127) 2025-09-29 19:41:48 +08:00
Levi 93614d46a8 release: v1.8.1 2025-09-14 10:10:30 +08:00
Levi 04b0e691eb fix: add timeout to server shutdown (#1116) 2025-09-13 21:19:28 +08:00
铺盖崽 7f08fa0b23 ci: update linux runner 24.04 (#1117) 2025-09-13 18:44:00 +08:00
Levi df6a51d23c feat: add notify when new version option (#1115) 2025-09-12 18:32:45 +08:00
Levi 46c379d556 fix: resolve incorrect download path for single file torrent (#1111) 2025-09-10 07:59:10 +08:00
Levi 46cd9f7a3e fix: windows tray disappearing after sleep/wake cycles (#1108) 2025-09-09 22:44:09 +08:00
Levi 5c2f66f4b6 fix: web auth config (#1094) 2025-09-09 18:35:04 +08:00
Krau cfdcb586ee fix: decode URL-encoded filename from contentDisposition header (#1099) 2025-09-04 18:22:30 +08:00
Levi 55e6bdc4f7 release: v1.8.0 2025-08-28 18:43:44 +08:00
Levi b31b42301e feat: update storage directory path for Windows (#1087) 2025-08-28 08:44:18 +08:00
Levi 7ce0d876b0 feat: add environment variables configuration support (#1088) 2025-08-28 08:21:02 +08:00
Levi 2fd2596aa0 ci: update macOS version to macos-14 (#1086) 2025-08-26 10:07:33 +08:00
Local-Gay-Communist e0973e4a34 feat: add german language support (#1084) 2025-08-22 09:26:37 +08:00
83 changed files with 5461 additions and 612 deletions

No files matched your search

+40 -19
View File
@@ -34,7 +34,7 @@ jobs:
build-windows:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
runs-on: windows-latest
runs-on: windows-2022
needs: [get-release]
steps:
- uses: actions/checkout@v3
@@ -198,7 +198,7 @@ jobs:
name: Build Amd64 Libraries
run: |
mkdir -p ui/flutter/macos/Frameworks
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib
cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host
cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater
@@ -206,17 +206,17 @@ jobs:
name: Build Universal Libraries
run: |
mkdir -p ui/flutter/macos/Frameworks
# arm64 lib
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop
go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host
go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater
# amd64 lib
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64
cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64
cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64
# universal lib
lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64
lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64
@@ -240,7 +240,7 @@ jobs:
EOF
mkdir dist
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
if [[ "${{ matrix.channel }}" == "arm64" ]]; then
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg
fi
@@ -265,24 +265,15 @@ jobs:
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- if: matrix.os == 'ubuntu-22.04'
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- if: matrix.os == 'ubuntu-22.04-arm'
uses: subosito/flutter-action@v2
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: master
- run: |
sudo apt update -y
# flutter build dependencies
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev
# appimage build dependencies
sudo apt install -y libfuse2 locate
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace
arch=$(uname -m)
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
chmod +x appimagetool
sudo mv appimagetool /usr/local/bin/
- name: Build
@@ -294,6 +285,29 @@ jobs:
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
cd ui/flutter
ICON_PATH=$(find assets -name "logo.png" -o -name "icon.png" | head -n 1)
[ -z "$ICON_PATH" ] && ICON_PATH="assets/icon.png" && touch assets/icon.png
convert "$ICON_PATH" assets/icon.svg
ICON_PATH="assets/icon.svg"
RPM_ARCH=$(uname -m)
mkdir -p linux/packaging/rpm
cat << EOF > linux/packaging/rpm/make_config.yaml
display_name: Gopeed
icon: $ICON_PATH
summary: High speed downloader.
description: A high speed downloader that supports all platforms.
group: Applications/Internet
license: GPL-3.0
url: https://github.com/GopeedLab/gopeed
vendor: GopeedLab
maintainer: GopeedLab <[email protected]>
build_arch: $RPM_ARCH
EOF
cat << EOF > distribute_options.yaml
output: dist/
releases:
@@ -313,12 +327,18 @@ jobs:
build_args:
dart-define:
UPDATE_CHANNEL: linuxDeb
- name: rpm
package:
platform: linux
target: rpm
build_args:
dart-define:
UPDATE_CHANNEL: linuxRpm
EOF
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
flutter_distributor release --name linux
cd dist/*
# Get current running architecture, x86_64 -> amd64 and aarch64 -> arm64
ARCH="amd64"
if [[ "${{ matrix.os }}" == *-arm ]]; then
ARCH="arm64"
@@ -326,6 +346,7 @@ jobs:
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -508,7 +529,7 @@ jobs:
overwrite: true
build-ios:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
runs-on: macos-latest
runs-on: macos-14
needs: [get-release]
steps:
- uses: actions/checkout@v3
+13 -7
View File
@@ -53,7 +53,13 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Run Unit tests.
run: go test -v -coverpkg=$(go list ./... | grep -v /cmd/ | grep -v /bind/ | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $(go list ./... | grep -v /cmd/ | grep -v /bind/)
run: |
# Get packages for testing and coverage (including cmd/web for flags_test.go)
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
# Filter out main.go from coverage report to avoid lowering coverage percentage
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
mv coverage_filtered.txt coverage.txt
- uses: codecov/codecov-action@v4
with:
files: ./coverage.txt
@@ -63,8 +69,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm ]
needs: [ test ]
os: [windows-2022, macos-latest, ubuntu-22.04, ubuntu-22.04-arm]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -96,8 +102,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ macos-latest, ubuntu-latest ]
needs: [ test ]
os: [macos-14, ubuntu-latest]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -129,7 +135,7 @@ jobs:
build-web:
name: Build web check
runs-on: ubuntu-latest
needs: [ test ]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -149,7 +155,7 @@ jobs:
build-docker:
name: Build docker check
runs-on: ubuntu-latest
needs: [ test ]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -170,6 +171,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
## 📱 WeChat Official Account
Follow our WeChat Official Account to get the latest updates and news.
<img src="_docs/img/weixin.png" width="200" />
## 💝 Donate
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -159,6 +160,12 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
### 🛠️ コマンドツール
## 📱 WeChat 公式アカウント
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
<img src="_docs/img/weixin.png" width="200" />
## 💝 寄付
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
+7
View File
@@ -5,6 +5,7 @@
[![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Tải về](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Ủng hộ](https://img.shields.io/badge/%24-ủng%20hộ-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -165,6 +166,12 @@ Sử dụng `go install`:
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
## 📱 WeChat Official Account
Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất.
<img src="_docs/img/weixin.png" width="200" />
## 💝 Quyên góp
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/zh/donate.html)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -169,6 +170,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
## 📱 微信公众号
关注公众号获取项目最新动态和资讯。
<img src="_docs/img/weixin.png" width="200" />
## 💝 赞助
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -166,6 +167,12 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
## 📱 微信公眾號
關注公眾號獲取項目最新動態和資訊。
<img src="_docs/img/weixin.png" width="200" />
## 💝 贊助
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+1
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
// +build !windows
package main
import (
"net"
"os"
"path/filepath"
)
func Dial() (net.Conn, error) {
// Get binary path
exe, err := os.Executable()
if err != nil {
return nil, err
}
return net.Dial("unix", filepath.Join(filepath.Dir(exe), "gopeed_host.sock"))
}
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"net"
"github.com/Microsoft/go-winio"
)
func Dial() (net.Conn, error) {
return winio.DialPipe(`\\.\pipe\gopeed_host`, nil)
}
+69 -24
View File
@@ -1,22 +1,24 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/pkg/browser"
"github.com/shirou/gopsutil/v4/process"
)
const identifier = "gopeed"
type Message struct {
Method string `json:"method"`
Meta map[string]any `json:"meta"`
Params json.RawMessage `json:"params"`
}
@@ -26,31 +28,74 @@ type Response struct {
Message string `json:"message,omitempty"`
}
var apiMap = map[string]func(params json.RawMessage) (data any, err error){
"ping": func(params json.RawMessage) (data any, err error) {
processes, err := process.Processes()
if err != nil {
return false, err
}
func check() (data bool, err error) {
conn, err := Dial()
if err != nil {
return false, err
}
defer conn.Close()
return true, nil
}
for _, p := range processes {
name, err := p.Name()
if err != nil {
continue
}
func wakeup(hidden bool) error {
running, _ := check()
if running {
return nil
}
if strings.Contains(strings.ToLower(name), strings.ToLower(identifier)) {
return true, nil
}
uri := "gopeed:"
if hidden {
uri = uri + "?hidden=true"
}
if err := browser.OpenURL(uri); err != nil {
return err
}
for i := 0; i < 10; i++ {
if ok, _ := check(); ok {
return nil
}
return false, nil
time.Sleep(1 * time.Second)
}
return fmt.Errorf("start gopeed failed")
}
var apiMap = map[string]func(message *Message) (data any, err error){
"ping": func(message *Message) (data any, err error) {
return check()
},
"create": func(params json.RawMessage) (data any, err error) {
var strParams string
if err = json.Unmarshal(params, &strParams); err != nil {
"create": func(message *Message) (data any, err error) {
buf, err := message.Params.MarshalJSON()
if err != nil {
return
}
err = browser.OpenURL(fmt.Sprintf("%s:///create?params=%s", identifier, strParams))
silent := false
if v, ok := message.Meta["silent"]; ok {
silent, _ = v.(bool)
}
if err := wakeup(silent); err != nil {
return nil, err
}
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return Dial()
},
},
Timeout: 10 * time.Second,
}
req, err := http.NewRequest("POST", "http://127.0.0.1/create", bytes.NewBuffer(buf))
if err != nil {
return
}
if message.Meta != nil {
metaJson, _ := json.Marshal(message.Meta)
req.Header.Set("X-Gopeed-Host-Meta", string(metaJson))
}
_, err = client.Do(req)
return
},
}
@@ -88,7 +133,7 @@ func main() {
var data any
var err error
if handler, ok := apiMap[message.Method]; ok {
data, err = handler(message.Params)
data, err = handler(&message)
} else {
err = errors.New("Unknown method: " + message.Method)
}
+5 -6
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"syscall"
"time"
@@ -19,6 +18,7 @@ func main() {
pid := flag.Int("pid", 0, "PID of the process to update")
updateChannel := flag.String("channel", "", "Update channel")
packagePath := flag.String("asset", "", "Path to the package asset")
exeDir := flag.String("exeDir", "", "Directory of the entry executable")
logPath := flag.String("log", "", "Log file path")
flag.Parse()
@@ -43,7 +43,7 @@ func main() {
restart bool
err error
)
if restart, err = update(*pid, *updateChannel, *packagePath); err != nil {
if restart, err = update(*pid, *updateChannel, *packagePath, *exeDir); err != nil {
log.Printf("Update failed: %v\n", err)
os.Exit(1)
}
@@ -61,7 +61,7 @@ func main() {
os.Exit(0)
}
func update(pid int, updateChannel, packagePath string) (restart bool, err error) {
func update(pid int, updateChannel, packagePath, exeDir string) (restart bool, err error) {
killSignalChan := make(chan any, 1)
go func() {
@@ -76,9 +76,8 @@ func update(pid int, updateChannel, packagePath string) (restart bool, err error
}
}()
appDir := filepath.Dir(os.Args[0])
log.Printf("Updating process updateChannel=%s packagePath=%s appDir=%s\n", updateChannel, packagePath, appDir)
if restart, err = install(killSignalChan, updateChannel, packagePath, appDir); err != nil {
log.Printf("Updating process updateChannel=%s packagePath=%s exeDir=%s\n", updateChannel, packagePath, exeDir)
if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {
return false, errors.Wrap(err, "failed to install package")
}
+9 -5
View File
@@ -25,16 +25,20 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
mountPoint := ""
for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "/Volumes/") {
fields := strings.Fields(line)
if len(fields) > 2 {
mountPoint = fields[len(fields)-1]
// Find the /Volumes/ path in the line
// hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed
// or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1
idx := strings.Index(line, "/Volumes/")
if idx != -1 {
// Extract everything from /Volumes/ onwards and trim whitespace
mountPoint = strings.TrimSpace(line[idx:])
break
}
}
}
if mountPoint == "" {
return fmt.Errorf("failed to get mount point")
return fmt.Errorf("failed to get mount point from hdiutil output: %s", string(output))
}
// Detach the mounted DMG
@@ -45,7 +49,7 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
return err
}
if len(matches) == 0 {
return fmt.Errorf("no .app found in dmg")
return fmt.Errorf("no .app found in dmg, mountPoint: %s", mountPoint)
}
killSignalChan <- nil
+166 -51
View File
@@ -3,19 +3,23 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/GopeedLab/gopeed/pkg/base"
)
type args struct {
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
StorageDir *string `json:"storageDir"`
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
StorageDir *string `json:"storageDir"`
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
// DownloadConfig when the first time to start the server, it will be configured as initial value
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
@@ -23,62 +27,173 @@ type args struct {
}
func parse() *args {
var cliArgs args
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
cliArgs.Username = flag.String("u", "gopeed", "Web Authentication Username")
cliArgs.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
cliArgs.StorageDir = flag.String("d", "", "Storage directory")
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
cfg := &args{}
cliConfig := loadCliArgs()
loadConfigFile(cfg, *cliConfig.configPath)
loadEnvVars(cfg)
// override with non-default command line arguments
overrideWithCliArgs(cfg, cliConfig)
// set default values
setDefaults(cfg, cliConfig)
return cfg
}
// loadCliArgs parses command line arguments and returns initial config
func loadCliArgs() *args {
cfg := &args{}
cfg.Address = flag.String("A", "0.0.0.0", "Bind Address")
cfg.Port = flag.Int("P", 9999, "Bind Port")
cfg.Username = flag.String("u", "gopeed", "Web Authentication Username")
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
cfg.StorageDir = flag.String("d", "", "Storage directory")
whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
cfg.configPath = flag.String("c", "./config.json", "Config file path")
flag.Parse()
// args priority: config file > cli args
cfgArgs := loadConfig(*cliArgs.configPath)
if cfgArgs.Address == nil {
cfgArgs.Address = cliArgs.Address
// Parse white download directories from comma-separated string
if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
dirs := strings.Split(*whiteDownloadDirs, ",")
for i := range dirs {
dirs[i] = strings.TrimSpace(dirs[i])
}
cfg.WhiteDownloadDirs = dirs
}
if cfgArgs.Port == nil {
cfgArgs.Port = cliArgs.Port
}
if cfgArgs.Username == nil {
cfgArgs.Username = cliArgs.Username
}
if cfgArgs.Password == nil {
cfgArgs.Password = cliArgs.Password
}
if cfgArgs.ApiToken == nil {
cfgArgs.ApiToken = cliArgs.ApiToken
}
if cfgArgs.StorageDir == nil {
cfgArgs.StorageDir = cliArgs.StorageDir
}
return cfgArgs
return cfg
}
func loadConfig(path string) *args {
var args args
// overrideWithCliArgs overrides config with non-empty command line arguments
func overrideWithCliArgs(cfg *args, cliConfig *args) {
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "A":
cfg.Address = cliConfig.Address
case "P":
cfg.Port = cliConfig.Port
case "u":
cfg.Username = cliConfig.Username
case "p":
cfg.Password = cliConfig.Password
case "T":
cfg.ApiToken = cliConfig.ApiToken
case "d":
cfg.StorageDir = cliConfig.StorageDir
case "w":
cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
case "c":
cfg.configPath = cliConfig.configPath
}
})
}
if !filepath.IsAbs(path) {
// setDefaults sets default values for any unset configuration fields
func setDefaults(cfg *args, cliConfig *args) {
if cfg.Address == nil {
cfg.Address = cliConfig.Address
}
if cfg.Port == nil {
cfg.Port = cliConfig.Port
}
if cfg.Username == nil {
cfg.Username = cliConfig.Username
}
if cfg.Password == nil {
cfg.Password = cliConfig.Password
}
if cfg.ApiToken == nil {
cfg.ApiToken = cliConfig.ApiToken
}
if cfg.StorageDir == nil {
cfg.StorageDir = cliConfig.StorageDir
}
}
// loadConfigFile loads configuration from file
func loadConfigFile(cfg *args, configPath string) {
if !filepath.IsAbs(configPath) {
dir, err := os.Getwd()
if err != nil {
fmt.Println("config dir get failed, reason:" + err.Error())
return &args
return
}
path = filepath.Join(dir, path)
configPath = filepath.Join(dir, configPath)
}
file, err := os.ReadFile(path)
file, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return &args
return
}
fmt.Println("config file read failed, reason:" + err.Error())
return &args
return
}
if err = json.Unmarshal(file, &args); err != nil {
fmt.Println("config file parse failed, reason:" + err.Error())
return &args
if err = json.Unmarshal(file, cfg); err != nil {
return
}
}
// loadEnvVars loads configuration from environment variables with prefix GOPEED_
func loadEnvVars(cfg *args) {
v := reflect.ValueOf(cfg).Elem()
t := reflect.TypeOf(cfg).Elem()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
// Get json tag as environment variable suffix
jsonTag := fieldType.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// Remove options like omitempty
if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 {
jsonTag = jsonTag[:commaIdx]
}
// Convert to uppercase and add GOPEED_ prefix
envKey := "GOPEED_" + strings.ToUpper(jsonTag)
envValue := os.Getenv(envKey)
if envValue == "" {
continue
}
// Set value based on field type
if field.Kind() == reflect.Ptr {
if field.IsNil() {
// Create new pointer instance
newVal := reflect.New(field.Type().Elem())
field.Set(newVal)
}
switch field.Type().Elem().Kind() {
case reflect.String:
field.Elem().SetString(envValue)
case reflect.Int:
if intVal, err := strconv.Atoi(envValue); err == nil {
field.Elem().SetInt(int64(intVal))
}
default:
// For complex types like DownloadConfig, try JSON unmarshaling
if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {
var config base.DownloaderStoreConfig
if err := json.Unmarshal([]byte(envValue), &config); err == nil {
field.Set(reflect.ValueOf(&config))
}
}
}
} else if field.Kind() == reflect.Slice {
// Handle non-pointer slice types (like []string for WhiteDownloadDirs)
if field.Type().Elem().Kind() == reflect.String {
dirs := strings.Split(envValue, ",")
for i := range dirs {
dirs[i] = strings.TrimSpace(dirs[i])
}
field.Set(reflect.ValueOf(dirs))
}
}
}
fmt.Printf("config file loaded: %s\n", path)
return &args
}
File diff suppressed because it is too large. Load diff
+17 -15
View File
@@ -6,11 +6,12 @@ package main
import (
"embed"
"fmt"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io/fs"
"os"
"path/filepath"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
//go:embed dist/*
@@ -31,28 +32,29 @@ func main() {
}
}
var dir string
var storageDir string
if args.StorageDir != nil && *args.StorageDir != "" {
dir = *args.StorageDir
storageDir = *args.StorageDir
} else {
exe, err := os.Executable()
if err != nil {
panic(err)
}
dir = filepath.Dir(exe)
storageDir = filepath.Join(filepath.Dir(exe), "storage")
}
cfg := &model.StartConfig{
Network: "tcp",
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
StorageDir: filepath.Join(dir, "storage"),
ApiToken: *args.ApiToken,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebAuth: webAuth,
Network: "tcp",
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
StorageDir: storageDir,
WhiteDownloadDirs: args.WhiteDownloadDirs,
ApiToken: *args.ApiToken,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebAuth: webAuth,
}
cmd.Start(cfg)
}
+3 -10
View File
@@ -27,29 +27,23 @@ require (
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // 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/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/stretchr/testify v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/Microsoft/go-winio v0.6.1
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
github.com/RoaringBitmap/roaring v1.9.4 // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect
@@ -126,7 +120,6 @@ require (
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/shirou/gopsutil/v4 v4.24.12
github.com/skeema/knownhosts v1.2.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tidwall/btree v1.7.0 // indirect
@@ -139,7 +132,7 @@ require (
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/text v0.24.0
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
-20
View File
@@ -146,8 +146,6 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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=
@@ -192,8 +190,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
@@ -308,8 +304,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY=
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
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=
@@ -398,8 +392,6 @@ 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/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
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=
@@ -450,8 +442,6 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
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/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
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.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@@ -488,10 +478,6 @@ github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EU
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
@@ -505,8 +491,6 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
@@ -596,7 +580,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -606,7 +589,6 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -622,8 +604,6 @@ 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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+6 -5
View File
@@ -3,17 +3,18 @@ package bt
import (
"encoding/base64"
"encoding/json"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
gohttp "net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
)
func TestFetcher_Resolve_Torrent(t *testing.T) {
+3 -2
View File
@@ -5,11 +5,12 @@ package bt
import (
"context"
"fmt"
"github.com/anacrolix/torrent/storage"
"io"
"os"
"path/filepath"
"github.com/anacrolix/torrent/storage"
"github.com/anacrolix/missinggo/v2"
"github.com/anacrolix/torrent/common"
@@ -34,7 +35,7 @@ func newFileOpts(opts newFileClientOpts) storage.ClientImplCloser {
if opts.FilePathMaker == nil {
opts.FilePathMaker = func(opts storage.FilePathMakerOpts) string {
var parts []string
if opts.Info.Name != metainfo.NoName {
if opts.Info.Length == 0 || opts.File.Path == nil {
parts = append(parts, opts.Info.Name)
}
return filepath.Join(append(parts, opts.File.Path...)...)
+164 -31
View File
@@ -18,13 +18,16 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/xiaoqidun/setft"
"golang.org/x/sync/errgroup"
"golang.org/x/text/encoding/simplifiedchinese"
)
const (
@@ -163,24 +166,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
}
contentDisposition := httpResp.Header.Get(base.HttpHeaderContentDisposition)
if contentDisposition != "" {
_, params, _ := mime.ParseMediaType(contentDisposition)
filename := params["filename"]
if filename != "" {
// Check if the filename is MIME encoded-word
if strings.HasPrefix(filename, "=?") {
decoder := new(mime.WordDecoder)
filename = strings.Replace(filename, "UTF8", "UTF-8", 1)
file.Name, _ = decoder.Decode(filename)
} else {
file.Name = filename
}
} else {
substr := "attachment; filename="
index := strings.Index(contentDisposition, substr)
if index != -1 {
file.Name = contentDisposition[index+len(substr):]
}
}
file.Name = parseFilename(contentDisposition)
}
// get file filePath by URL
if file.Name == "" {
@@ -385,19 +371,20 @@ func (f *Fetcher) run(index int, ctx context.Context) (err error) {
resp *http.Response
)
f.redirectLock.Lock()
if f.redirectURL != "" {
redirectURL := f.redirectURL
if redirectURL != "" {
f.redirectLock.Unlock()
}
err = func() (err error) {
} else {
// Only hold the lock when we need to potentially set redirectURL
defer func() {
if f.redirectURL == "" {
if err == nil {
f.redirectURL = resp.Request.URL.String()
}
f.redirectLock.Unlock()
if redirectURL == "" && err == nil {
f.redirectURL = resp.Request.URL.String()
}
f.redirectLock.Unlock()
}()
}
err = func() (err error) {
httpReq, err = f.buildRequest(ctx, f.meta.Req)
if err != nil {
return
@@ -616,12 +603,158 @@ func (f *Fetcher) buildClient() *http.Client {
}
}
func decodeMangledString(mangled string) string {
rawBytes := make([]byte, 0, len(mangled)*3)
for _, r := range mangled {
rawBytes = append(rawBytes, byte(r))
// parseFilename extracts filename from Content-Disposition header
// It handles multiple encoding scenarios:
// 1. RFC 5987/RFC 2231 format: filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip (preferred, checked first)
// 2. MIME encoded-word: filename="=?UTF-8?B?5rWL6K+VLnppcA==?="
// 3. URL-encoded: filename="%E6%B5%8B%E8%AF%95.zip"
// 4. Plain ASCII filename
//
// The key fix is checking filename*= first before mime.ParseMediaType, because
// some servers send Content-Disposition headers with invalid characters that cause
// mime.ParseMediaType to fail, but the filename*= parameter is still valid.
func parseFilename(contentDisposition string) string {
// First, try to find filename*= (RFC 5987 format, most reliable for non-ASCII)
if filename := parseFilenameExtended(contentDisposition); filename != "" {
return filename
}
return string(rawBytes)
// Try standard MIME parsing for regular filename= parameter
_, params, err := mime.ParseMediaType(contentDisposition)
if err == nil {
if filename := params["filename"]; filename != "" {
return decodeFilenameParam(filename)
}
}
// Fallback: manual parsing if mime.ParseMediaType fails
return parseFilenameFallback(contentDisposition)
}
// parseFilenameExtended parses RFC 5987/RFC 2231 extended parameter format
// Format: filename*=charset'language'value (e.g., UTF-8''%E6%B5%8B%E8%AF%95.zip)
func parseFilenameExtended(cd string) string {
// Look for filename*= (case-insensitive)
lower := strings.ToLower(cd)
idx := strings.Index(lower, "filename*=")
if idx == -1 {
return ""
}
// Extract the value after filename*=
value := cd[idx+len("filename*="):]
// Find the end of the value (next ; or end of string)
if endIdx := strings.Index(value, ";"); endIdx != -1 {
value = value[:endIdx]
}
value = strings.TrimSpace(value)
// Parse charset'language'encoded-value format
// Common format: UTF-8''%E6%B5%8B%E8%AF%95.zip
parts := strings.SplitN(value, "''", 2)
if len(parts) == 2 {
// parts[0] is charset (e.g., "UTF-8")
// parts[1] is percent-encoded value
decoded, err := url.QueryUnescape(parts[1])
if err == nil {
return decoded
}
}
// Try with single quote delimiter as well (some servers use this)
parts = strings.SplitN(value, "'", 3)
if len(parts) >= 3 {
decoded, err := url.QueryUnescape(parts[2])
if err == nil {
return decoded
}
}
return ""
}
// decodeFilenameParam decodes a filename parameter value
// Handles MIME encoded-word, URL encoding, and GBK encoding fallback
func decodeFilenameParam(filename string) string {
// Check if the filename is MIME encoded-word (e.g., =?UTF-8?B?...?=)
if strings.HasPrefix(filename, "=?") {
decoder := new(mime.WordDecoder)
// Some servers use "UTF8" instead of "UTF-8", create a normalized copy
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
return decoded
}
}
// Try URL decoding
decoded := util.TryUrlQueryUnescape(filename)
// Check if the result is valid UTF-8. If not, try GBK decoding.
// This handles the case where Chinese Windows servers send GBK-encoded filenames
// which appear as garbled characters (e.g., "下载地址.zip" -> "���ص�ַ.zip")
if !utf8.ValidString(decoded) {
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
return gbkDecoded
}
}
return decoded
}
// gbkDecoder is a reusable GBK decoder for better performance
var gbkDecoder = simplifiedchinese.GBK.NewDecoder()
// tryDecodeGBK attempts to decode a string as GBK/GB2312/GB18030 encoding
// Returns empty string if decoding fails or result is not valid UTF-8
func tryDecodeGBK(s string) string {
// GBK uses 1-2 bytes per character. Single-byte chars are 0x00-0x7F (ASCII compatible).
// Double-byte chars have first byte 0x81-0xFE and second byte 0x40-0xFE.
// Skip if string is empty or all ASCII (valid UTF-8)
if len(s) == 0 {
return ""
}
// Create a fresh decoder since the transform state may be corrupted
decoder := simplifiedchinese.GBK.NewDecoder()
decoded, err := decoder.Bytes([]byte(s))
if err != nil {
return ""
}
result := string(decoded)
if utf8.ValidString(result) {
return result
}
return ""
}
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
func parseFilenameFallback(cd string) string {
// Look for filename= (case-insensitive)
lower := strings.ToLower(cd)
idx := strings.Index(lower, "filename=")
if idx == -1 {
return ""
}
// Skip "filename=" prefix
value := cd[idx+len("filename="):]
// Find the end of the value
if endIdx := strings.Index(value, ";"); endIdx != -1 {
value = value[:endIdx]
}
value = strings.TrimSpace(value)
// Remove quotes if present
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
return decodeFilenameParam(value)
}
type fetcherData struct {
+39 -3
View File
@@ -42,7 +42,7 @@ func TestFetcher_Resolve(t *testing.T) {
Range: false,
Files: []*base.FileInfo{
{
Name: "测试.zip",
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
@@ -52,7 +52,7 @@ func TestFetcher_Resolve(t *testing.T) {
Range: false,
Files: []*base.FileInfo{
{
Name: "测试.zip",
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
@@ -62,7 +62,7 @@ func TestFetcher_Resolve(t *testing.T) {
Range: false,
Files: []*base.FileInfo{
{
Name: "测试.zip",
Name: test.TestChineseFileName,
Size: 0,
},
},
@@ -77,6 +77,42 @@ func TestFetcher_Resolve(t *testing.T) {
},
},
}, t)
// Test mixed encoding Content-Disposition where mime.ParseMediaType fails
// due to invalid characters, but filename*= contains the correct UTF-8 encoded name
testResolve(test.StartTestCustomServer, "mixed-encoding", &base.Resource{
Size: test.BuildSize,
Range: false,
Files: []*base.FileInfo{
{
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
}, t)
// Test filename*= only (RFC 5987 format)
testResolve(test.StartTestCustomServer, "filename-star", &base.Resource{
Size: test.BuildSize,
Range: false,
Files: []*base.FileInfo{
{
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
}, t)
// Test GBK-encoded filename (common on Chinese Windows servers)
// Before fix: "测试.zip" sent as GBK bytes -> parsed as "²âÊÔ.zip" (garbled)
// After fix: correctly decoded back to "测试.zip"
testResolve(test.StartTestCustomServer, "gbk-encoded", &base.Resource{
Size: test.BuildSize,
Range: false,
Files: []*base.FileInfo{
{
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
}, t)
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
+59 -3
View File
@@ -6,8 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/armon/go-socks5"
"io"
"net"
"net/http"
@@ -17,6 +15,10 @@ import (
"strings"
"sync/atomic"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/armon/go-socks5"
"golang.org/x/text/encoding/simplifiedchinese"
)
const (
@@ -34,6 +36,10 @@ const (
DownloadRename = "download (1).data"
DownloadFile = Dir + DownloadName
DownloadRenameFile = Dir + DownloadRename
// TestChineseFileName is a common test filename with Chinese characters
// Used to test Content-Disposition parsing with various encodings
TestChineseFileName = "测试.zip"
)
func StartTestFileServer() net.Listener {
@@ -103,7 +109,57 @@ func StartTestCustomServer() net.Listener {
io.Copy(writer, file)
})
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Disposition", "attachment; filename=测试.zip")
writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
// due to invalid characters like <a> tags in the filename.
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
// This simulates a server that sends a garbled filename= with special chars that cause
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
// The filename*= should be preferred and correctly parsed.
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for filename*= only (RFC 5987 format)
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
// This simulates the case where Chinese characters are sent as GBK bytes
// which appear as garbled characters when interpreted as UTF-8.
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
// Our fix detects invalid UTF-8 and attempts GBK decoding.
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
// Encode TestChineseFileName as GBK
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
// Send GBK bytes directly in filename (simulating broken server behavior)
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
+13
View File
@@ -182,6 +182,7 @@ type DownloaderStoreConfig struct {
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
Extra map[string]any `json:"extra"`
Proxy *DownloaderProxyConfig `json:"proxy"`
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
}
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
@@ -194,6 +195,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.Proxy == nil {
cfg.Proxy = &DownloaderProxyConfig{}
}
if cfg.Webhook == nil {
cfg.Webhook = &WebhookConfig{}
}
return cfg
}
@@ -216,9 +220,18 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
if cfg.Proxy == nil {
cfg.Proxy = beforeCfg.Proxy
}
if cfg.Webhook == nil {
cfg.Webhook = beforeCfg.Webhook
}
return cfg
}
// WebhookConfig is the webhook configuration
type WebhookConfig struct {
Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks
URLs []string `json:"urls"` // URLs is the list of webhook URLs
}
type DownloaderProxyConfig struct {
Enable bool `json:"enable"`
// System is the flag that use system proxy
+7 -1
View File
@@ -18,6 +18,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
},
},
{
@@ -29,6 +30,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
MaxRunning: 10,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
},
},
{
@@ -43,7 +45,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
ProtocolConfig: map[string]any{
"key": "value",
},
Proxy: &DownloaderProxyConfig{},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
},
},
{
@@ -59,6 +62,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
Proxy: &DownloaderProxyConfig{
Enable: true,
},
Webhook: &WebhookConfig{},
},
},
}
@@ -71,6 +75,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
ProtocolConfig: tt.fields.ProtocolConfig,
Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
}
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Init() = %v, want %v", got, tt.want)
@@ -255,6 +260,7 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
ProtocolConfig: tt.fields.ProtocolConfig,
Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
}
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Merge() = %v, want %v", got, tt.want)
+20 -14
View File
@@ -2,15 +2,6 @@ package download
import (
"errors"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/rs/zerolog"
"github.com/rs/zerolog/pkgerrors"
"math"
gohttp "net/http"
"net/url"
@@ -22,6 +13,16 @@ import (
"sync"
"sync/atomic"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/rs/zerolog"
"github.com/rs/zerolog/pkgerrors"
)
const (
@@ -532,7 +533,7 @@ func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
if filter == nil || filter.IsEmpty() {
return d.deleteAll()
return d.deleteAll(force)
}
deleteTasks := d.GetTasksByFilter(filter)
@@ -577,7 +578,7 @@ func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
return
}
func (d *Downloader) deleteAll() (err error) {
func (d *Downloader) deleteAll(force bool) (err error) {
var deleteTasksTemp []*Task
func() {
d.lock.Lock()
@@ -591,7 +592,7 @@ func (d *Downloader) deleteAll() (err error) {
}()
for _, task := range deleteTasksTemp {
if err = d.doDelete(task, true); err != nil {
if err = d.doDelete(task, force); err != nil {
return
}
}
@@ -856,6 +857,7 @@ func (d *Downloader) watch(task *Task) {
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.triggerOnDone(task)
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
downloadFilePath := task.Meta.SingleFilepath()
@@ -886,6 +888,7 @@ func (d *Downloader) doOnError(task *Task, err error) {
d.emit(EventKeyError, task, err)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.triggerWebhooks(WebhookEventDownloadError, task, err)
}
}
@@ -940,10 +943,13 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
opts.Path = storeConfig.DownloadDir
}
// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
opts.Path = util.ReplacePathPlaceholders(opts.Path)
// if enable white download directory, check if the download directory is in the white list
if len(d.cfg.DownloadDirWhiteList) > 0 {
if len(d.cfg.WhiteDownloadDirs) > 0 {
inWhiteList := false
for _, dir := range d.cfg.DownloadDirWhiteList {
for _, dir := range d.cfg.WhiteDownloadDirs {
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
inWhiteList = true
break
+1 -1
View File
@@ -91,7 +91,7 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
defer listener.Close()
downloader := NewDownloader(&DownloaderConfig{
DownloadDirWhiteList: []string{"./downloads"},
WhiteDownloadDirs: []string{"./downloads"},
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
+6 -4
View File
@@ -131,6 +131,7 @@ type XMLHttpRequest struct {
responseHeaders http.Header
aborted bool
client *req.Client
fingerprint string
WithCredentials bool `json:"withCredentials"`
Upload *XMLHttpRequestUpload `json:"upload"`
@@ -164,7 +165,7 @@ func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
}
func (xhr *XMLHttpRequest) Send(data goja.Value) {
setFingerprint(xhr.client)
setFingerprint(xhr.client, xhr.fingerprint)
d := xhr.parseData(data)
var (
@@ -381,7 +382,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
}
instance := &XMLHttpRequest{
client: client,
client: client,
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
Upload: &XMLHttpRequestUpload{
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
@@ -395,8 +397,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
runtime.Set("__gopeed_setFingerprint", func(fingerprint Fingerprint) {
currentFingerprint = fingerprint
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
runtime.Set(FingerprintMagicKey, fingerprint)
})
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
return err
@@ -1,21 +1,19 @@
package xhr
import (
"github.com/imroc/req/v3"
)
import "github.com/imroc/req/v3"
type Fingerprint string
const (
fingerprintChrome Fingerprint = "chrome"
fingerprintFirefox Fingerprint = "firefox"
fingerprintSafari Fingerprint = "safari"
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
fingerprintChrome = "chrome"
fingerprintFirefox = "firefox"
fingerprintSafari = "safari"
)
var currentFingerprint Fingerprint = ""
func setFingerprint(client *req.Client) {
switch currentFingerprint {
func setFingerprint(client *req.Client, fingerprint string) {
switch fingerprint {
case fingerprintChrome:
client.ImpersonateChrome()
case fingerprintFirefox:
+13
View File
@@ -22,3 +22,16 @@ func AssertError[T error](err error) (t T, r bool) {
}
return
}
func SafeGet[T any](vm *goja.Runtime, name string) T {
v := vm.Get(name)
if v == nil {
var init T
return init
}
if e, ok := v.Export().(T); ok {
return e
}
var init T
return init
}
+4 -4
View File
@@ -137,10 +137,10 @@ type DownloaderConfig struct {
Controller *controller.Controller
FetchManagers []fetcher.FetcherManager
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
DownloadDirWhiteList []string `json:"downloadDirWhiteList"`
RefreshInterval int // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
WhiteDownloadDirs []string
ProductionMode bool
+229
View File
@@ -0,0 +1,229 @@
package download
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
const (
webhookTimeout = 10 * time.Second
)
// WebhookEvent represents the type of webhook event
type WebhookEvent string
const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
)
// WebhookData is the data sent to webhook URLs
type WebhookData struct {
Event WebhookEvent `json:"event"`
Time int64 `json:"time"` // Unix timestamp in milliseconds
Payload *WebhookPayload `json:"payload"`
}
// WebhookPayload contains the task data
type WebhookPayload struct {
Task *Task `json:"task"`
}
// getWebhookUrls extracts webhook URLs from config
// Supports both new webhook config format and legacy extra field for backward compatibility
func (d *Downloader) getWebhookUrls() []string {
cfg := d.cfg.DownloaderStoreConfig
if cfg == nil {
return nil
}
// Try new webhook config first
if cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 {
urls := make([]string, 0, len(cfg.Webhook.URLs))
for _, url := range cfg.Webhook.URLs {
if url != "" {
urls = append(urls, url)
}
}
if len(urls) > 0 {
return urls
}
}
// Fall back to legacy extra field for backward compatibility
if cfg.Extra == nil {
return nil
}
webhookUrls, ok := cfg.Extra["webhookUrls"]
if !ok {
return nil
}
// Try direct string slice first
if urlsStr, ok := webhookUrls.([]string); ok {
if len(urlsStr) == 0 {
return nil
}
return urlsStr
}
// Convert []interface{} to []string
if urlsInterface, ok := webhookUrls.([]any); ok {
if len(urlsInterface) == 0 {
return nil
}
urls := make([]string, 0, len(urlsInterface))
for _, urlInterface := range urlsInterface {
if url, ok := urlInterface.(string); ok && url != "" {
urls = append(urls, url)
}
}
if len(urls) == 0 {
return nil
}
return urls
}
return nil
}
// sendWebhookToUrl sends webhook data to a single URL
// Returns the HTTP status code and any error that occurred
func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {
if url == "" {
return 0, fmt.Errorf("webhook URL is empty")
}
jsonData, err := json.Marshal(data)
if err != nil {
return 0, err
}
client := &http.Client{
Timeout: webhookTimeout,
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Gopeed-Webhook/1.0")
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
// triggerWebhooks sends webhook notifications to all configured URLs
func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {
urls := d.getWebhookUrls()
if len(urls) == 0 {
return
}
data := &WebhookData{
Event: event,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: task.clone(),
},
}
go d.sendWebhooks(urls, data)
}
func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {
for _, url := range urls {
if url == "" {
continue
}
go func(webhookUrl string) {
statusCode, err := d.sendWebhookToUrl(webhookUrl, data)
if err != nil {
d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request")
return
}
if statusCode >= 200 && statusCode < 300 {
d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully")
} else {
d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status")
}
}(url)
}
}
// SendTestWebhook sends a test webhook with a simulated payload
// Returns error if any webhook URL does not respond with HTTP 200
func (d *Downloader) SendTestWebhook() error {
urls := d.getWebhookUrls()
if len(urls) == 0 {
return nil
}
for _, url := range urls {
if url == "" {
continue
}
if err := d.TestWebhookUrl(url); err != nil {
return err
}
}
return nil
}
// TestWebhookUrl tests a single webhook URL with a simulated payload
// Returns error if the URL does not respond with HTTP 200
func (d *Downloader) TestWebhookUrl(url string) error {
// Create a simulated test task with minimal required fields
testTask := NewTask()
testTask.Protocol = "http"
testTask.Status = base.DownloadStatusDone
testTask.Meta = &fetcher.FetcherMeta{
Req: &base.Request{
URL: "https://example.com/test-file.zip",
},
Opts: &base.Options{
Name: "test-file.zip",
Path: "/downloads",
},
Res: &base.Resource{
Size: 1024 * 1024 * 100, // 100MB
Files: []*base.FileInfo{
{Name: "test-file.zip", Size: 1024 * 1024 * 100},
},
},
}
// Create test data
testData := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: testTask,
},
}
statusCode, err := d.sendWebhookToUrl(url, testData)
if err != nil {
return err
}
if statusCode != http.StatusOK {
return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode)
}
return nil
}
File diff suppressed because it is too large. Load diff
+11
View File
@@ -389,3 +389,14 @@ func getServerConfig() *base.DownloaderStoreConfig {
cfg, _ := Downloader.GetConfig()
return cfg
}
func TestWebhook(w http.ResponseWriter, r *http.Request) {
var req model.TestWebhookReq
if ReadJson(r, w, &req) {
if err := Downloader.TestWebhookUrl(req.URL); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
}
+6
View File
@@ -0,0 +1,6 @@
package model
// TestWebhookReq is the request body for testing a single webhook URL
type TestWebhookReq struct {
URL string `json:"url"`
}
+8 -4
View File
@@ -65,7 +65,9 @@ func Stop() {
}()
if srv != nil {
if err := srv.Shutdown(context.TODO()); err != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
Downloader.Logger.Warn().Err(err).Msg("shutdown server failed")
}
}
@@ -83,8 +85,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
startCfg.Init()
downloadCfg := &download.DownloaderConfig{
ProductionMode: startCfg.ProductionMode,
RefreshInterval: startCfg.RefreshInterval,
ProductionMode: startCfg.ProductionMode,
RefreshInterval: startCfg.RefreshInterval,
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
}
if startCfg.Storage == model.StorageBolt {
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
@@ -138,6 +141,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
enableApiToken := startCfg.ApiToken != ""
@@ -190,7 +194,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
}
if enableBasicAuth {
if r.URL.Path == "/api/web/login" {
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" {
h.ServeHTTP(w, r)
return
}
+115 -4
View File
@@ -6,10 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io"
"net"
"net/http"
@@ -20,6 +16,11 @@ import (
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
var (
@@ -606,6 +607,116 @@ func TestDoProxy(t *testing.T) {
})
}
func TestTestWebhook(t *testing.T) {
doTest(func() {
// Set up a mock webhook server
webhookReceived := false
var receivedData map[string]interface{}
var wg sync.WaitGroup
wg.Add(1)
webhookServer := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if err := json.Unmarshal(body, &receivedData); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Check Content-Type
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json")
}
webhookReceived = true
w.WriteHeader(http.StatusOK)
wg.Done()
}),
}
// Start webhook server
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
webhookPort := listener.Addr().(*net.TCPAddr).Port
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort)
go webhookServer.Serve(listener)
defer webhookServer.Close()
// Test with valid webhook URL
httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: webhookURL,
})
// Wait for webhook to be received
wg.Wait()
if !webhookReceived {
t.Error("TestWebhook() webhook was not received")
}
// Verify webhook data structure
if receivedData["event"] == nil {
t.Error("TestWebhook() missing 'event' field")
}
if receivedData["time"] == nil {
t.Error("TestWebhook() missing 'time' field")
}
if receivedData["payload"] == nil {
t.Error("TestWebhook() missing 'payload' field")
}
// Test with invalid webhook URL
code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook",
})
checkCode(code, model.CodeError)
// Test with empty URL
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: "",
})
checkCode(code, model.CodeError)
// Test with webhook server returning non-200 status
wg.Add(1)
badWebhookServer := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
wg.Done()
}),
}
badListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
badWebhookPort := badListener.Addr().(*net.TCPAddr).Port
badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort)
go badWebhookServer.Serve(badListener)
defer badWebhookServer.Close()
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: badWebhookURL,
})
checkCode(code, model.CodeError)
wg.Wait()
})
}
func TestApiToken(t *testing.T) {
var cfg = &model.StartConfig{}
cfg.Init()
+34 -3
View File
@@ -8,6 +8,7 @@ import (
syspath "path"
"path/filepath"
"strings"
"time"
)
func Dir(path string) string {
@@ -50,12 +51,17 @@ func CheckDuplicateAndRename(path string) (string, error) {
return "", err
}
index := strings.LastIndex(name, ".")
ext := syspath.Ext(name)
var nameTpl string
if index == -1 {
// Special case: if the extension is the entire filename (like .gitignore),
// or if index of last dot is 0 (starts with dot), treat it as no extension
if ext == "" || ext == name || (len(ext) > 0 && strings.LastIndex(name, ".") == 0) {
// No extension or hidden file without extension
nameTpl = name + " (%d)"
} else {
nameTpl = name[:index] + " (%d)" + name[index:]
// Has extension
nameWithoutExt := name[:len(name)-len(ext)]
nameTpl = nameWithoutExt + " (%d)" + ext
}
for i := 1; ; i++ {
newName := fmt.Sprintf(nameTpl, i)
@@ -194,3 +200,28 @@ func ReplaceInvalidFilename(path string) string {
}
return path
}
// ReplacePathPlaceholders replaces date placeholders in a path with actual values
// Supported placeholders:
// - %year% - Current year (e.g., 2025)
// - %month% - Current month (01-12)
// - %day% - Current day (01-31)
// - %date% - Full date format (2025-01-01)
func ReplacePathPlaceholders(path string) string {
if path == "" {
return ""
}
now := time.Now()
year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", now.Month())
day := fmt.Sprintf("%02d", now.Day())
date := fmt.Sprintf("%s-%s-%s", year, month, day)
path = strings.ReplaceAll(path, "%year%", year)
path = strings.ReplaceAll(path, "%month%", month)
path = strings.ReplaceAll(path, "%day%", day)
path = strings.ReplaceAll(path, "%date%", date)
return path
}
+90
View File
@@ -1,8 +1,11 @@
package util
import (
"fmt"
"os"
"strings"
"testing"
"time"
)
func TestDir(t *testing.T) {
@@ -106,13 +109,28 @@ func TestSafeRemove(t *testing.T) {
}
func TestCheckDuplicateAndRename(t *testing.T) {
// Test with extension
doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
// Test without extension
doCheckDuplicateAndRename(t, []string{}, "a", "a")
doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
// Test hidden files (starting with dot)
doCheckDuplicateAndRename(t, []string{}, ".gitignore", ".gitignore")
doCheckDuplicateAndRename(t, []string{".gitignore"}, ".gitignore", ".gitignore (1)")
doCheckDuplicateAndRename(t, []string{".gitignore", ".gitignore (1)"}, ".gitignore", ".gitignore (2)")
// Test hidden files with extension
doCheckDuplicateAndRename(t, []string{}, ".config.json", ".config.json")
doCheckDuplicateAndRename(t, []string{".config.json"}, ".config.json", ".config (1).json")
// Test multiple dots
doCheckDuplicateAndRename(t, []string{}, "test.tar.gz", "test.tar.gz")
doCheckDuplicateAndRename(t, []string{"test.tar.gz"}, "test.tar.gz", "test.tar (1).gz")
}
func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {
@@ -225,3 +243,75 @@ func TestReplaceInvalidFilename(t *testing.T) {
})
}
}
func TestReplacePathPlaceholders(t *testing.T) {
now := time.Now()
year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", now.Month())
day := fmt.Sprintf("%02d", now.Day())
date := fmt.Sprintf("%s-%s-%s", year, month, day)
tests := []struct {
name string
path string
want string
}{
{
name: "empty path",
path: "",
want: "",
},
{
name: "no placeholders",
path: "/home/user/Downloads",
want: "/home/user/Downloads",
},
{
name: "year placeholder",
path: "/Downloads/%year%",
want: "/Downloads/" + year,
},
{
name: "month placeholder",
path: "/Downloads/%month%",
want: "/Downloads/" + month,
},
{
name: "day placeholder",
path: "/Downloads/%day%",
want: "/Downloads/" + day,
},
{
name: "date placeholder",
path: "/Downloads/%date%",
want: "/Downloads/" + date,
},
{
name: "multiple placeholders",
path: "/Downloads/%year%-%month%",
want: "/Downloads/" + year + "-" + month,
},
{
name: "mixed path with placeholders",
path: "/home/user/Downloads/%year%/%month%/%day%",
want: "/home/user/Downloads/" + year + "/" + month + "/" + day,
},
{
name: "windows style path",
path: "D:\\Downloads\\%year%-%month%",
want: "D:\\Downloads\\" + year + "-" + month,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ReplacePathPlaceholders(tt.path)
if !strings.Contains(got, year) && tt.path != "" && strings.Contains(tt.path, "%year%") {
t.Errorf("ReplacePathPlaceholders() = %v, want containing year %v", got, year)
}
if got != tt.want {
t.Errorf("ReplacePathPlaceholders() = %v, want %v", got, tt.want)
}
})
}
}
+10
View File
@@ -56,3 +56,13 @@ func ProxyUrlToHandler(proxyUrl *url.URL) func(*http.Request) (*url.URL, error)
}
return http.ProxyURL(proxyUrl)
}
// TryUrlQueryUnescape tries to unescape a URL-encoded string.
//
// If unescaping fails, it returns the original string.
func TryUrlQueryUnescape(s string) string {
if decoded, err := url.QueryUnescape(s); err == nil {
return decoded
}
return s
}
+21
View File
@@ -128,3 +128,24 @@ func TestParseDataUri(t *testing.T) {
})
}
}
func TestTryURLDecode(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal.txt", "normal.txt"},
{"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"},
{"hello%20world.txt", "hello world.txt"},
{"bad%2-text", "bad%2-text"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := TryUrlQueryUnescape(tt.input)
if got != tt.expected {
t.Errorf("TryUrlQueryUnescape(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
+5
View File
@@ -236,6 +236,11 @@ Future<void> updateExtension(String identity) async {
() => _client.dio.post("api/v1/extensions/$identity/update"), null);
}
Future<void> testWebhook(String url) async {
return _parse(
() => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null);
}
Future<String> login(LoginReq loginReq) async {
return _parse(() => _client.dio.post("api/web/login", data: loginReq),
(data) => data as String);
@@ -9,6 +9,7 @@ class DownloaderConfig {
ProtocolConfig protocolConfig = ProtocolConfig();
ExtraConfig extra = ExtraConfig();
ProxyConfig proxy = ProxyConfig();
WebhookConfig webhook = WebhookConfig();
DownloaderConfig({
this.downloadDir = '',
@@ -81,8 +82,11 @@ class ExtraConfig {
bool lastDeleteTaskKeep;
bool defaultDirectDownload;
bool defaultBtClient;
bool notifyWhenNewVersion;
List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt();
ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
ExtraConfig({
this.themeMode = '',
@@ -90,6 +94,8 @@ class ExtraConfig {
this.lastDeleteTaskKeep = false,
this.defaultDirectDownload = false,
this.defaultBtClient = true,
this.notifyWhenNewVersion = true,
this.downloadCategories = const [],
});
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
@@ -98,6 +104,45 @@ class ExtraConfig {
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
}
@JsonSerializable()
class DownloadCategory {
String name;
String path;
bool isBuiltIn;
String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')
bool
isDeleted; // Mark built-in categories as deleted instead of removing them
DownloadCategory({
required this.name,
required this.path,
this.isBuiltIn = false,
this.nameKey,
this.isDeleted = false,
});
factory DownloadCategory.fromJson(Map<String, dynamic> json) =>
_$DownloadCategoryFromJson(json);
Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);
}
@JsonSerializable()
class WebhookConfig {
bool enable;
List<String> urls;
WebhookConfig({
this.enable = false,
this.urls = const [],
});
factory WebhookConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? WebhookConfig() : _$WebhookConfigFromJson(json);
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
}
@JsonSerializable()
class ProxyConfig {
bool enable;
@@ -138,3 +183,46 @@ class ExtraConfigBt {
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
}
enum GithubMirrorType {
jsdelivr,
ghProxy,
}
@JsonSerializable()
class GithubMirror {
GithubMirrorType type;
String url;
bool isBuiltIn;
bool isDeleted;
GithubMirror({
required this.type,
required this.url,
this.isBuiltIn = false,
this.isDeleted = false,
});
factory GithubMirror.fromJson(Map<String, dynamic> json) =>
_$GithubMirrorFromJson(json);
Map<String, dynamic> toJson() => _$GithubMirrorToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ExtraConfigGithubMirror {
bool enabled;
List<GithubMirror> mirrors;
ExtraConfigGithubMirror({
this.enabled = true,
this.mirrors = const [],
});
factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>
json == null
? ExtraConfigGithubMirror()
: _$ExtraConfigGithubMirrorFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);
}
@@ -14,7 +14,9 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..protocolConfig = ProtocolConfig.fromJson(
json['protocolConfig'] as Map<String, dynamic>?)
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>);
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook =
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?);
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
<String, dynamic>{
@@ -23,6 +25,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
'protocolConfig': instance.protocolConfig.toJson(),
'extra': instance.extra.toJson(),
'proxy': instance.proxy.toJson(),
'webhook': instance.webhook.toJson(),
};
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
@@ -74,7 +77,15 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
)..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
)
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
..githubMirror = ExtraConfigGithubMirror.fromJson(
json['githubMirror'] as Map<String, dynamic>?);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
<String, dynamic>{
@@ -83,7 +94,52 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
'lastDeleteTaskKeep': instance.lastDeleteTaskKeep,
'defaultDirectDownload': instance.defaultDirectDownload,
'defaultBtClient': instance.defaultBtClient,
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'downloadCategories':
instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(),
'githubMirror': instance.githubMirror.toJson(),
};
DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) =>
DownloadCategory(
name: json['name'] as String,
path: json['path'] as String,
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
nameKey: json['nameKey'] as String?,
isDeleted: json['isDeleted'] as bool? ?? false,
);
Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) {
final val = <String, dynamic>{
'name': instance.name,
'path': instance.path,
'isBuiltIn': instance.isBuiltIn,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('nameKey', instance.nameKey);
val['isDeleted'] = instance.isDeleted;
return val;
}
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) =>
WebhookConfig(
enable: json['enable'] as bool? ?? false,
urls:
(json['urls'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
);
Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
<String, dynamic>{
'enable': instance.enable,
'urls': instance.urls,
};
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
@@ -139,3 +195,40 @@ Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) {
val['customTrackers'] = instance.customTrackers;
return val;
}
GithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(
type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),
url: json['url'] as String,
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
isDeleted: json['isDeleted'] as bool? ?? false,
);
Map<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) =>
<String, dynamic>{
'type': _$GithubMirrorTypeEnumMap[instance.type]!,
'url': instance.url,
'isBuiltIn': instance.isBuiltIn,
'isDeleted': instance.isDeleted,
};
const _$GithubMirrorTypeEnumMap = {
GithubMirrorType.jsdelivr: 'jsdelivr',
GithubMirrorType.ghProxy: 'ghProxy',
};
ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(
Map<String, dynamic> json) =>
ExtraConfigGithubMirror(
enabled: json['enabled'] as bool? ?? true,
mirrors: (json['mirrors'] as List<dynamic>?)
?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$ExtraConfigGithubMirrorToJson(
ExtraConfigGithubMirror instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/app_controller.dart';
class AppBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<AppController>(
() => AppController(),
);
}
}
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:get/get.dart';
import 'package:launch_at_startup/launch_at_startup.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:share_handler/share_handler.dart';
import 'package:tray_manager/tray_manager.dart';
@@ -16,6 +17,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../api/model/request.dart';
import '../../../../core/common/start_config.dart';
@@ -31,7 +33,7 @@ import '../../../../util/package_info.dart';
import '../../../../util/updater.dart';
import '../../../../util/util.dart';
import '../../../routes/app_pages.dart';
import '../../create/dto/create_router_params.dart';
import '../../../rpc/rpc.dart';
import '../../redirect/views/redirect_view.dart';
const unixSocketPath = 'gopeed.sock';
@@ -78,6 +80,9 @@ class AppController extends GetxController with WindowListener, TrayListener {
_initTray().onError(
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
_initRpcServer().onError((error, stackTrace) =>
logger.w("initRpcServer error", error, stackTrace));
_initForegroundTask().onError((error, stackTrace) =>
logger.w("initForegroundTask error", error, stackTrace));
@@ -119,7 +124,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
@override
void onTrayIconRightMouseDown() {
trayManager.popUpContextMenu();
trayManager.popUpContextMenu(bringAppToFront: true);
}
@override
@@ -271,6 +276,37 @@ class AppController extends GetxController with WindowListener, TrayListener {
trayManager.addListener(this);
}
Future<void> _initRpcServer() async {
if (!Util.isDesktop()) {
return;
}
try {
await startRpcServer({
"/create": (ctx) async {
final meta =
ctx.request.headers["X-Gopeed-Host-Meta"]?.firstOrNull ?? "{}";
final jsonMeta = jsonDecode(meta);
final silent = jsonMeta['silent'] as bool? ?? false;
final params = await ctx.readText();
final createTaskParams = _decodeToCreatTaskParams(params);
if (!silent) {
await windowManager.show();
_handleToCreate0(createTaskParams);
} else {
try {
await createTask(createTaskParams);
} catch (e) {
logger.w(
"create task from extension fail", e, StackTrace.current);
}
}
},
});
} catch (e) {
logger.w("start rpc server fail", e, StackTrace.current);
}
}
Future<void> _initForegroundTask() async {
if (!Util.isMobile()) {
return;
@@ -317,13 +353,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
if (uri.path == "/create") {
final params = uri.queryParameters["params"];
if (params?.isNotEmpty == true) {
final safeParams = params!.replaceAll(" ", "+");
final paramsJson =
String.fromCharCodes(base64Decode(base64.normalize(safeParams)));
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
arguments: RedirectArgs(Routes.CREATE,
arguments:
CreateRouterParams.fromJson(jsonDecode(paramsJson))));
_handleToCreate(params!);
return;
}
Get.rootDelegate.offAndToNamed(Routes.CREATE);
@@ -346,7 +376,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
}
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
arguments: RedirectArgs(Routes.CREATE,
arguments: CreateRouterParams(req: Request(url: path))));
arguments: CreateTask(req: Request(url: path))));
}
String runningAddress() {
@@ -475,6 +505,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
// default select all tracker subscribe urls
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
}
final proxy = config.proxy;
if (proxy.scheme.isEmpty) {
proxy.scheme = 'http';
@@ -492,6 +523,68 @@ class AppController extends GetxController with WindowListener, TrayListener {
config.downloadDir = './';
}
}
// Initialize default download categories if empty
if (extra.downloadCategories.isEmpty) {
_initDefaultDownloadCategories();
}
// Initialize default GitHub mirrors if empty
if (extra.githubMirror.mirrors.isEmpty) {
_initDefaultGithubMirrors();
}
}
void _initDefaultDownloadCategories() {
final extra = downloaderConfig.value.extra;
final downloadDir = downloaderConfig.value.downloadDir;
// Add default built-in categories with i18n keys
// No need to set initial name value, it will be retrieved via nameKey
extra.downloadCategories = [
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Music'),
isBuiltIn: true,
nameKey: 'categoryMusic',
),
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Video'),
isBuiltIn: true,
nameKey: 'categoryVideo',
),
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Document'),
isBuiltIn: true,
nameKey: 'categoryDocument',
),
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Program'),
isBuiltIn: true,
nameKey: 'categoryProgram',
),
];
}
void _initDefaultGithubMirrors() {
final extra = downloaderConfig.value.extra;
// Add default built-in GitHub mirrors
extra.githubMirror.mirrors = [
GithubMirror(
type: GithubMirrorType.jsdelivr,
url: 'https://fastly.jsdelivr.net/gh',
isBuiltIn: true,
),
GithubMirror(
type: GithubMirrorType.ghProxy,
url: 'https://fastgit.cc',
isBuiltIn: true,
),
];
}
Future<void> _initLaunchAtStartup() async {
@@ -506,6 +599,11 @@ class AppController extends GetxController with WindowListener, TrayListener {
}
Future<void> _initCheckUpdate() async {
// Check if auto check update is enabled
if (!downloaderConfig.value.extra.notifyWhenNewVersion) {
return;
}
final versionInfo = await checkUpdate();
if (versionInfo != null) {
await showUpdateDialog(Get.context!, versionInfo);
@@ -519,4 +617,21 @@ class AppController extends GetxController with WindowListener, TrayListener {
apiToken: startConfig.value.apiToken));
await putConfig(downloaderConfig.value);
}
CreateTask _decodeToCreatTaskParams(String params) {
final safeParams = params.replaceAll('"', "").replaceAll(" ", "+");
final paramsJson =
String.fromCharCodes(base64Decode(base64.normalize(safeParams)));
return CreateTask.fromJson(jsonDecode(paramsJson));
}
_handleToCreate(String params) {
final createTaskParams = _decodeToCreatTaskParams(params);
_handleToCreate0(createTaskParams);
}
_handleToCreate0(CreateTask createTaskParams) {
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
arguments: RedirectArgs(Routes.CREATE, arguments: createTaskParams));
}
}
@@ -1,24 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../../api/model/options.dart';
import '../../../../api/model/request.dart';
part 'create_router_params.g.dart';
@JsonSerializable(explicitToJson: true)
class CreateRouterParams {
Request? req;
Options? opt;
CreateRouterParams({
this.req,
this.opt,
});
factory CreateRouterParams.fromJson(
Map<String, dynamic> json,
) =>
_$CreateRouterParamsFromJson(json);
Map<String, dynamic> toJson() => _$CreateRouterParamsToJson(this);
}
@@ -1,31 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_router_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreateRouterParams _$CreateRouterParamsFromJson(Map<String, dynamic> json) =>
CreateRouterParams(
req: json['req'] == null
? null
: Request.fromJson(json['req'] as Map<String, dynamic>),
opt: json['opt'] == null
? null
: Options.fromJson(json['opt'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CreateRouterParamsToJson(CreateRouterParams instance) {
final val = <String, dynamic>{};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('req', instance.req?.toJson());
writeNotNull('opt', instance.opt?.toJson());
return val;
}
@@ -12,6 +12,7 @@ import 'package:rounded_loading_button_plus/rounded_loading_button.dart';
import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart';
import '../../../../api/model/create_task_batch.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../api/model/options.dart';
import '../../../../api/model/request.dart';
import '../../../../api/model/resolve_result.dart';
@@ -27,7 +28,6 @@ import '../../../views/file_tree_view.dart';
import '../../app/controllers/app_controller.dart';
import '../../history/views/history_view.dart';
import '../controllers/create_controller.dart';
import '../dto/create_router_params.dart';
class CreateView extends GetView<CreateController> {
final _confirmFormKey = GlobalKey<FormState>();
@@ -73,10 +73,12 @@ class CreateView extends GetView<CreateController> {
.toString();
}
if (_pathController.text.isEmpty) {
_pathController.text = appController.downloaderConfig.value.downloadDir;
// Render placeholders when initializing the path
final downloadDir = appController.downloaderConfig.value.downloadDir;
_pathController.text = renderPathPlaceholders(downloadDir);
}
final CreateRouterParams? routerParams = Get.rootDelegate.arguments();
final CreateTask? routerParams = Get.rootDelegate.arguments();
if (routerParams?.req?.url.isNotEmpty ?? false) {
// get url from route arguments
final url = routerParams!.req!.url;
@@ -329,6 +331,8 @@ class CreateView extends GetView<CreateController> {
DirectorySelector(
controller: _pathController,
),
// Category selector
_buildCategorySelector(appController),
Obx(
() => Visibility(
visible: controller.showAdvanced.value,
@@ -913,4 +917,66 @@ class CreateView extends GetView<CreateController> {
],
));
}
Widget _buildCategorySelector(AppController appController) {
final categories =
appController.downloaderConfig.value.extra.downloadCategories
.where((c) => !c.isDeleted) // Filter out deleted categories
.toList();
if (categories.isEmpty) {
return const SizedBox.shrink();
}
// Helper to get display name
String getCategoryDisplayName(DownloadCategory category) {
if (category.nameKey != null && category.nameKey!.isNotEmpty) {
return category.nameKey!.tr;
}
return category.name;
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Text(
'selectCategory'.tr,
style: TextStyle(
color: Get.theme.hintColor,
fontSize: 12,
),
),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: categories.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: OutlinedButton(
onPressed: () {
_pathController.text = renderPathPlaceholders(category.path);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
minimumSize: Size.zero,
),
child: Text(
getCategoryDisplayName(category),
style: const TextStyle(fontSize: 12),
),
),
);
}).toList(),
),
),
),
],
),
);
}
}
Loaded 50 of 83 files, more files were not shown because too many files have changed in this diff. Show more