diff --git a/go.mod b/go.mod index 0cdc6463..a98d899e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f3284324..87392db3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 35d54147..573ffac1 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -857,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() @@ -887,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) } } diff --git a/pkg/download/webhook.go b/pkg/download/webhook.go new file mode 100644 index 00000000..d095b065 --- /dev/null +++ b/pkg/download/webhook.go @@ -0,0 +1,210 @@ +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 and converts webhook URLs from config +func (d *Downloader) getWebhookUrls() []string { + cfg := d.cfg.DownloaderStoreConfig + if cfg == nil || 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 +} diff --git a/pkg/download/webhook_test.go b/pkg/download/webhook_test.go new file mode 100644 index 00000000..521bb217 --- /dev/null +++ b/pkg/download/webhook_test.go @@ -0,0 +1,799 @@ +package download + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" +) + +var mockFetcherMeta = fetcher.FetcherMeta{ + Req: &base.Request{ + URL: "https://example.com/test.zip", + }, + Opts: &base.Options{ + Path: "/downloads", + }, + Res: &base.Resource{ + Size: 1024 * 1024, + Files: []*base.FileInfo{ + {Name: "test.zip", Size: 1024 * 1024}, + }, + }, +} + +func TestWebhook_TriggerOnDone(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + // Configure webhook URLs + cfg, _ := downloader.GetConfig() + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + cfg.Extra["webhookUrls"] = []string{server.URL} + downloader.PutConfig(cfg) + + // Create a mock task + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + // Trigger webhook + downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) + + select { + case data := <-receivedData: + if data.Event != WebhookEventDownloadDone { + t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event) + } + if data.Payload == nil || data.Payload.Task == nil { + t.Error("Expected payload.task to be present") + } else if data.Payload.Task.ID != task.ID { + t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID) + } + if data.Time == 0 { + t.Error("Expected time to be set") + } + case <-time.After(2 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + +func TestWebhook_TriggerOnError(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + // Configure webhook URLs + cfg, _ := downloader.GetConfig() + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + cfg.Extra["webhookUrls"] = []string{server.URL} + downloader.PutConfig(cfg) + + // Create a mock task + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + // Trigger webhook with error + testError := http.ErrServerClosed + downloader.triggerWebhooks(WebhookEventDownloadError, task, testError) + + select { + case data := <-receivedData: + if data.Event != WebhookEventDownloadError { + t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event) + } + if data.Payload == nil || data.Payload.Task == nil { + t.Error("Expected payload.task to be present") + } + case <-time.After(2 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + +func TestWebhook_SendTestWebhook(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + // Configure webhook URLs + cfg, _ := downloader.GetConfig() + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + cfg.Extra["webhookUrls"] = []string{server.URL} + downloader.PutConfig(cfg) + + // Send test webhook + err := downloader.SendTestWebhook() + if err != nil { + t.Errorf("SendTestWebhook failed: %v", err) + } + + select { + case data := <-receivedData: + if data.Event != WebhookEventDownloadDone { + t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event) + } + if data.Payload == nil || data.Payload.Task == nil { + t.Error("Expected payload.task to be present") + } + if data.Time == 0 { + t.Error("Expected time to be set") + } + case <-time.After(2 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + +func TestWebhook_NoWebhookConfigured(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + // Create a mock task + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + // Trigger webhook (should not panic with no webhooks configured) + downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) + + // Send test webhook (should not panic) + err := downloader.SendTestWebhook() + if err != nil { + t.Errorf("SendTestWebhook failed: %v", err) + } + }) +} + +func TestWebhook_MultipleUrls(t *testing.T) { + count := 0 + server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count++ + w.WriteHeader(http.StatusOK) + })) + defer server1.Close() + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count++ + w.WriteHeader(http.StatusOK) + })) + defer server2.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + // Configure multiple webhook URLs + cfg, _ := downloader.GetConfig() + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL} + downloader.PutConfig(cfg) + + // Create a mock task + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + // Trigger webhook + downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) + + // Wait for webhooks + time.Sleep(500 * time.Millisecond) + + if count != 2 { + t.Errorf("Expected 2 webhook calls, got %d", count) + } + }) +} + +func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) { + // Test that SendTestWebhook returns error for non-200 status codes + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) // 500 + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + // Configure webhook URLs + cfg, _ := downloader.GetConfig() + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + cfg.Extra["webhookUrls"] = []string{server.URL} + downloader.PutConfig(cfg) + + // Send test webhook - should fail with non-200 status + err := downloader.SendTestWebhook() + if err == nil { + t.Error("Expected SendTestWebhook to return error for non-200 status") + } + }) +} + +func TestWebhook_TestWebhookFailsOn201(t *testing.T) { + // Test that SendTestWebhook returns error for 201 (only 200 is success) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) // 201 + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + // Configure webhook URLs + cfg, _ := downloader.GetConfig() + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + cfg.Extra["webhookUrls"] = []string{server.URL} + downloader.PutConfig(cfg) + + // Send test webhook - should fail with 201 status (only 200 is success) + err := downloader.SendTestWebhook() + if err == nil { + t.Error("Expected SendTestWebhook to return error for 201 status") + } + }) +} + +func TestWebhook_TestWebhookUrl(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + // Test single URL + err := downloader.TestWebhookUrl(server.URL) + if err != nil { + t.Errorf("TestWebhookUrl failed: %v", err) + } + + select { + case data := <-receivedData: + if data.Event != WebhookEventDownloadDone { + t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event) + } + if data.Payload == nil || data.Payload.Task == nil { + t.Error("Expected payload.task to be present") + } + case <-time.After(2 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + +func TestWebhook_TestWebhookUrlEmpty(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + // Test with empty URL - should return error + err := downloader.TestWebhookUrl("") + if err == nil { + t.Error("Expected TestWebhookUrl to return error for empty URL") + } + }) +} + +func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + urls := downloader.getWebhookUrls() + if urls != nil { + t.Errorf("Expected nil, got %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = nil + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if urls != nil { + t.Errorf("Expected nil, got %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["otherKey"] = "value" + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if urls != nil { + t.Errorf("Expected nil, got %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []string{"http://example.com", "http://example2.com"} + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if len(urls) != 2 { + t.Errorf("Expected 2 URLs, got %d", len(urls)) + } + if urls[0] != "http://example.com" || urls[1] != "http://example2.com" { + t.Errorf("URLs don't match expected values: %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", "http://example2.com"} + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if len(urls) != 2 { + t.Errorf("Expected 2 URLs, got %d", len(urls)) + } + if urls[0] != "http://example.com" || urls[1] != "http://example2.com" { + t.Errorf("URLs don't match expected values: %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []string{} + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if urls != nil { + t.Errorf("Expected nil, got %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []interface{}{"", "", ""} + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if urls != nil { + t.Errorf("Expected nil for all empty strings, got %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", 123, "http://example2.com", nil, ""} + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if len(urls) != 2 { + t.Errorf("Expected 2 valid URLs (ignoring non-strings and empty), got %d: %v", len(urls), urls) + } + if urls[0] != "http://example.com" || urls[1] != "http://example2.com" { + t.Errorf("URLs don't match expected values: %v", urls) + } + }) +} + +func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = "not-a-slice" + downloader.PutConfig(cfg) + + urls := downloader.getWebhookUrls() + if urls != nil { + t.Errorf("Expected nil for invalid type, got %v", urls) + } + }) +} + +func TestWebhook_SendWebhookToUrl_Success(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify headers + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" { + t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent")) + } + + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + data := &WebhookData{ + Event: WebhookEventDownloadDone, + Time: time.Now().UnixMilli(), + Payload: &WebhookPayload{ + Task: task, + }, + } + + statusCode, err := downloader.sendWebhookToUrl(server.URL, data) + if err != nil { + t.Errorf("sendWebhookToUrl failed: %v", err) + } + if statusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", statusCode) + } + + select { + case received := <-receivedData: + if received.Event != WebhookEventDownloadDone { + t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event) + } + case <-time.After(1 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + +func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + data := &WebhookData{ + Event: WebhookEventDownloadDone, + Time: time.Now().UnixMilli(), + } + + _, err := downloader.sendWebhookToUrl("", data) + if err == nil { + t.Error("Expected error for empty URL") + } + if err.Error() != "webhook URL is empty" { + t.Errorf("Expected 'webhook URL is empty' error, got: %v", err) + } + }) +} + +func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + data := &WebhookData{ + Event: WebhookEventDownloadDone, + Time: time.Now().UnixMilli(), + } + + _, err := downloader.sendWebhookToUrl("://invalid-url", data) + if err == nil { + t.Error("Expected error for invalid URL") + } + }) +} + +func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + data := &WebhookData{ + Event: WebhookEventDownloadDone, + Time: time.Now().UnixMilli(), + } + + _, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data) + if err == nil { + t.Error("Expected error for non-existent host") + } + }) +} + +func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) { + // Create a server that delays response beyond webhook timeout + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + data := &WebhookData{ + Event: WebhookEventDownloadDone, + Time: time.Now().UnixMilli(), + } + + _, err := downloader.sendWebhookToUrl(server.URL, data) + if err == nil { + t.Error("Expected timeout error") + } + }) +} + +func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) { + testCases := []struct { + name string + statusCode int + }{ + {"200 OK", http.StatusOK}, + {"201 Created", http.StatusCreated}, + {"204 No Content", http.StatusNoContent}, + {"400 Bad Request", http.StatusBadRequest}, + {"404 Not Found", http.StatusNotFound}, + {"500 Internal Server Error", http.StatusInternalServerError}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.statusCode) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + data := &WebhookData{ + Event: WebhookEventDownloadDone, + Time: time.Now().UnixMilli(), + } + + statusCode, err := downloader.sendWebhookToUrl(server.URL, data) + if err != nil { + t.Errorf("sendWebhookToUrl failed: %v", err) + } + if statusCode != tc.statusCode { + t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode) + } + }) + }) + } +} + +func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []string{server.URL, "", server.URL} + downloader.PutConfig(cfg) + + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) + + time.Sleep(500 * time.Millisecond) + + if requestCount != 2 { + t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount) + } + }) +} + +func TestWebhook_WebhookDataStructure(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []string{server.URL} + downloader.PutConfig(cfg) + + task := NewTask() + task.Protocol = "http" + task.Status = base.DownloadStatusDone + task.Meta = &mockFetcherMeta + + downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) + + select { + case data := <-receivedData: + // Verify event + if data.Event != WebhookEventDownloadDone { + t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event) + } + // Verify time is set + if data.Time == 0 { + t.Error("Expected time to be set") + } + // Verify payload + if data.Payload == nil { + t.Error("Expected payload to be present") + } + if data.Payload.Task == nil { + t.Error("Expected task in payload to be present") + } + // Verify task is cloned (has same ID but different pointer) + if data.Payload.Task.ID != task.ID { + t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID) + } + case <-time.After(2 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + +func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []string{} + downloader.PutConfig(cfg) + + err := downloader.SendTestWebhook() + if err != nil { + t.Errorf("Expected no error for empty URLs, got: %v", err) + } + }) +} + +func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) { + // First server returns 200 + server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server1.Close() + + // Second server returns 500 + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server2.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Extra = make(map[string]any) + cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL} + downloader.PutConfig(cfg) + + // Should fail because server2 returns 500 + err := downloader.SendTestWebhook() + if err == nil { + t.Error("Expected error when one server returns non-200 status") + } + }) +} + +func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) { + setupWebhookTest(t, func(downloader *Downloader) { + err := downloader.TestWebhookUrl("://invalid") + if err == nil { + t.Error("Expected error for invalid URL") + } + }) +} + +func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + err := downloader.TestWebhookUrl(server.URL) + if err != nil { + t.Errorf("TestWebhookUrl failed: %v", err) + } + + select { + case data := <-receivedData: + // Verify it's a test webhook + if data.Event != WebhookEventDownloadDone { + t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event) + } + if data.Payload == nil || data.Payload.Task == nil { + t.Error("Expected payload with task") + } + // Verify test task properties + task := data.Payload.Task + if task.Protocol != "http" { + t.Errorf("Expected protocol 'http', got '%s'", task.Protocol) + } + if task.Status != base.DownloadStatusDone { + t.Errorf("Expected status Done, got %s", task.Status) + } + if task.Meta == nil || task.Meta.Req == nil { + t.Error("Expected meta and request in test task") + } + if task.Meta.Req.URL != "https://example.com/test-file.zip" { + t.Errorf("Expected test URL, got %s", task.Meta.Req.URL) + } + case <-time.After(2 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + +func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) { + defaultDownloader.Setup() + defaultDownloader.cfg.StorageDir = ".test_storage" + defaultDownloader.cfg.DownloadDir = ".test_download" + defer func() { + defaultDownloader.Clear() + os.RemoveAll(defaultDownloader.cfg.StorageDir) + os.RemoveAll(defaultDownloader.cfg.DownloadDir) + }() + fn(defaultDownloader) +} diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 7c42f9ee..6c388fcf 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -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()) + } +} diff --git a/pkg/rest/model/webhook.go b/pkg/rest/model/webhook.go new file mode 100644 index 00000000..e82bfc80 --- /dev/null +++ b/pkg/rest/model/webhook.go @@ -0,0 +1,6 @@ +package model + +// TestWebhookReq is the request body for testing a single webhook URL +type TestWebhookReq struct { + URL string `json:"url"` +} diff --git a/pkg/rest/server.go b/pkg/rest/server.go index b931258e..3c431e61 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -141,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 != "" diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index 4ad67240..dc57a946 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -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() diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index e8686daf..e7056b04 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -236,6 +236,11 @@ Future updateExtension(String identity) async { () => _client.dio.post("api/v1/extensions/$identity/update"), null); } +Future testWebhook(String url) async { + return _parse( + () => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null); +} + Future login(LoginReq loginReq) async { return _parse(() => _client.dio.post("api/web/login", data: loginReq), (data) => data as String); diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index 4685db02..e62c7dd4 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -82,6 +82,7 @@ class ExtraConfig { bool defaultDirectDownload; bool defaultBtClient; bool notifyWhenNewVersion; + List webhookUrls; ExtraConfigBt bt = ExtraConfigBt(); @@ -92,6 +93,7 @@ class ExtraConfig { this.defaultDirectDownload = false, this.defaultBtClient = true, this.notifyWhenNewVersion = true, + this.webhookUrls = const [], }); factory ExtraConfig.fromJson(Map? json) => diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 3603b0e9..f5a3485e 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -75,6 +75,10 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false, defaultBtClient: json['defaultBtClient'] as bool? ?? true, notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, + webhookUrls: (json['webhookUrls'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], )..bt = ExtraConfigBt.fromJson(json['bt'] as Map); Map _$ExtraConfigToJson(ExtraConfig instance) => @@ -85,6 +89,7 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => 'defaultDirectDownload': instance.defaultDirectDownload, 'defaultBtClient': instance.defaultBtClient, 'notifyWhenNewVersion': instance.notifyWhenNewVersion, + 'webhookUrls': instance.webhookUrls, 'bt': instance.bt.toJson(), }; diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index 4e8bfe01..f0d2abf9 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -10,6 +10,7 @@ import 'package:intl/intl.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../../api/api.dart' as api; import '../../../../api/model/downloader_config.dart'; import '../../../../i18n/message.dart'; import '../../../../util/input_formatter.dart'; @@ -24,6 +25,7 @@ import '../../../views/check_list_view.dart'; import '../../../views/directory_selector.dart'; import '../../../views/open_in_new.dart'; import '../../../views/outlined_button_loading.dart'; +import '../../../views/text_button_loading.dart'; import '../../app/controllers/app_controller.dart'; import '../controllers/setting_controller.dart'; @@ -891,6 +893,72 @@ class SettingView extends GetView { ); }); + // advanced config webhook items + final buildWebhook = _buildConfigItem( + 'webhook', + () => 'items'.trParams( + {'count': downloaderCfg.value.extra.webhookUrls.length.toString()}), + (Key key) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'webhookDesc'.tr, + style: Theme.of(Get.context!).textTheme.bodySmall, + ), + _padding, + // List of existing webhook URLs + ...downloaderCfg.value.extra.webhookUrls + .asMap() + .entries + .map((entry) { + final index = entry.key; + final url = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Expanded( + child: Text( + url, + style: Theme.of(Get.context!).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.edit, size: 20), + tooltip: 'edit'.tr, + onPressed: () { + _showWebhookDialog(index: index, initialUrl: url); + }, + ), + IconButton( + icon: const Icon(Icons.delete, size: 20), + tooltip: 'delete'.tr, + onPressed: () async { + downloaderCfg.value.extra.webhookUrls.removeAt(index); + downloaderCfg.refresh(); + await debounceSave(); + }, + ), + ], + ), + ); + }), + _padding, + // Add button + OutlinedButton.icon( + onPressed: () { + _showWebhookDialog(); + }, + icon: const Icon(Icons.add), + label: Text('add'.tr), + ), + ], + ); + }, + ); + // advanced config log items start buildLogsDir() { return ListTile( @@ -1035,6 +1103,7 @@ class SettingView extends GetView { Card( child: Column( children: _addDivider([ + buildWebhook(), buildLogsDir(), ]), )), @@ -1047,6 +1116,120 @@ class SettingView extends GetView { }); } + void _showWebhookDialog({int? index, String? initialUrl}) { + final urlController = TextEditingController(text: initialUrl ?? ''); + final testController = OutlinedButtonLoadingController(); + final saveController = TextButtonLoadingController(); + final appController = Get.find(); + final downloaderCfg = appController.downloaderConfig; + final isEdit = index != null; + final formKey = GlobalKey(); + + showDialog( + context: Get.context!, + builder: (dialogContext) => AlertDialog( + title: Text(isEdit ? 'edit'.tr : 'add'.tr), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: urlController, + decoration: InputDecoration( + hintText: 'webhookUrlHint'.tr, + ), + keyboardType: TextInputType.url, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'required'.tr; + } + final url = value.trim().toLowerCase(); + if (!url.startsWith('http://') && + !url.startsWith('https://')) { + return 'webhookUrlInvalid'.tr; + } + try { + Uri.parse(value.trim()); + } catch (e) { + return 'webhookUrlInvalid'.tr; + } + return null; + }, + ), + ], + ), + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + // Test button on the left + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: OutlinedButtonLoading( + controller: testController, + onPressed: () async { + if (!formKey.currentState!.validate()) { + return; + } + final url = urlController.text.trim(); + if (url.isEmpty) return; + testController.start(); + try { + await api.testWebhook(url); + showMessage('tip'.tr, 'webhookTestSuccess'.tr); + } catch (e) { + showErrorMessage('webhookTestFail'.tr); + } finally { + testController.stop(); + } + }, + child: Text('webhookTest'.tr), + ), + ), + // Cancel and Confirm on the right + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text('cancel'.tr), + ), + TextButtonLoading( + controller: saveController, + onPressed: () async { + if (!formKey.currentState!.validate()) { + return; + } + final url = urlController.text.trim(); + if (url.isEmpty) return; + + saveController.start(); + try { + if (isEdit) { + downloaderCfg.value.extra.webhookUrls[index] = url; + } else { + downloaderCfg.value.extra.webhookUrls.add(url); + } + downloaderCfg.refresh(); + await appController.saveConfig(); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + showErrorMessage(e); + } finally { + saveController.stop(); + } + }, + child: Text('confirm'.tr), + ), + ], + ), + ], + ), + ); + } + void _tapInputWidget(GlobalKey key) { if (key.currentContext == null) { return; diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index a40bcc90..f18a6e84 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -65,6 +65,14 @@ const enUS = { 'effectAfterRestart': 'Effect after restart', 'developer': 'Developer', 'logDirectory': 'Log Directory', + 'webhook': 'Webhook', + 'webhookDesc': 'Send HTTP POST notifications when tasks complete or fail', + 'webhookUrlHint': 'Enter webhook URL', + 'webhookTest': 'Test', + 'webhookTestSuccess': 'Webhook test successful', + 'webhookTestFail': 'Webhook test failed', + 'webhookUrlInvalid': 'Please enter a valid HTTP or HTTPS URL', + 'required': 'This field is required', 'show': 'Show', 'continue': 'Continue', 'pause': 'Pause', @@ -73,6 +81,8 @@ const enUS = { 'deleteTask': 'Delete @count tasks', 'deleteTaskTip': 'Keep downloaded files', 'delete': 'Delete', + 'add': 'Add', + 'edit': 'Edit', 'newVersionTitle': 'Discover new version @version', 'newVersionUpdate': 'Update Now', 'newVersionLater': 'Later', diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index b0debcd2..252ddc00 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -64,6 +64,14 @@ const zhCN = { 'effectAfterRestart': '此配置项将在重启应用后生效', 'developer': '开发者', 'logDirectory': '日志目录', + 'webhook': 'Webhook 推送', + 'webhookDesc': '在任务完成或失败时发送 HTTP POST 通知', + 'webhookUrlHint': '请输入 Webhook URL', + 'webhookTest': '测试', + 'webhookTestSuccess': 'Webhook 测试成功', + 'webhookTestFail': 'Webhook 测试失败', + 'webhookUrlInvalid': '请输入有效的 HTTP 或 HTTPS 链接', + 'required': '此项为必填项', 'show': '显示', 'continue': '继续', 'pause': '暂停', @@ -72,6 +80,8 @@ const zhCN = { 'deleteTask': '删除 @count 个任务', 'deleteTaskTip': '保留已下载的文件', 'delete': '删除', + 'add': '添加', + 'edit': '编辑', 'newVersionTitle': '发现新版本 @version', 'newVersionUpdate': '立即更新', 'newVersionLater': '稍后再说',