Skip to content

Commit f78a06d

Browse files
committed
inital webhook support
1 parent 6762226 commit f78a06d

4 files changed

Lines changed: 1012 additions & 30 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,31 @@ export CZDS_PASSWORD="your_password"
127127
czds download -verbose
128128
```
129129

130+
### Webhook Integration
131+
132+
The download command supports webhook integration for pre-download approval and post-download notifications via environment variables:
133+
134+
**Environment Variables:**
135+
136+
- `PRECHECK_WEBHOOK_URL` - Batch pre-download approval endpoint (full URL)
137+
- `NOTIFICATION_WEBHOOK_URL` - Post-download notification endpoint (full URL)
138+
139+
**Pre-Check:** All zones are checked in a single batch request before downloads start. Zones with `should_download: false` are skipped.
140+
141+
**Notifications:** Sent immediately after each zone download completes (single-zone batches to maintain batch API format).
142+
143+
**Failure Handling:** If pre-check fails (network error, server error), all zones proceed anyway (fail-open).
144+
145+
**Example:**
146+
147+
```bash
148+
export PRECHECK_WEBHOOK_URL=https://example.com/addzone/check
149+
export NOTIFICATION_WEBHOOK_URL=https://example.com/addzone
150+
czds download
151+
```
152+
153+
See [cmd/webhook/README.md](cmd/webhook/README.md) for API details and server examples.
154+
130155
## Request Subcommand
131156

132157
Submit a new zone request or modify an existing CZDS request. Be sure to view and accept the terms and conditions with the `-terms` flag.

cmd/download.go

Lines changed: 143 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"flag"
77
"fmt"
8+
"log"
89
"math/rand"
910
"os"
1011
"path"
@@ -13,6 +14,7 @@ import (
1314
"time"
1415

1516
"github.com/lanrat/czds"
17+
"github.com/lanrat/czds/cmd/webhook"
1618
"golang.org/x/sync/errgroup"
1719
)
1820

@@ -41,9 +43,10 @@ type DownloadConfig struct {
4143
// zoneInfo contains information about a zone file download task,
4244
// including the zone name, download URL, and local file path.
4345
type zoneInfo struct {
44-
Name string // Zone name (e.g., "com", "org")
45-
Dl string // Download URL for the zone file
46-
FullPath string // Full local file path where zone will be saved
46+
Name string // Zone name (e.g., "com", "org")
47+
Dl string // Download URL for the zone file
48+
FullPath string // Full local file path where zone will be saved
49+
Date time.Time // Last modified date of the zone file
4750
}
4851

4952
// downloadCmd creates and configures the download subcommand for the czds CLI.
@@ -124,16 +127,34 @@ func downloadCmd() *Command {
124127
return fmt.Errorf("authentication failed: %w", err)
125128
}
126129

127-
return runDownload(ctx, client, &config, gf.Verbose)
130+
return runDownload(ctx, client, &config, &gf)
128131
},
129132
}
130133
}
131134

132135
// runDownload executes the download command logic with parallel workers and retry handling.
133136
// It manages output directory creation, link retrieval, worker coordination, and error handling.
134-
func runDownload(ctx context.Context, client *czds.Client, config *DownloadConfig, verbose bool) error {
137+
func runDownload(ctx context.Context, client *czds.Client, config *DownloadConfig, gf *GlobalFlags) error {
138+
verbose := gf.Verbose
139+
quiet := config.Quiet
140+
141+
// Initialize webhook client from environment variables
142+
webhookClient, err := webhook.NewFromEnv()
143+
if err != nil {
144+
return err
145+
}
146+
147+
// Configure webhook client if enabled
148+
if webhookClient != nil {
149+
webhookClient.SetHeader("User-Agent", fmt.Sprintf("lanrat/czds %s", version))
150+
webhookClient.SetHeader("X-CZDS-Username", gf.Username)
151+
webhookClient.SetHeader("X-Zone-Source", "czds")
152+
if verbose {
153+
webhookClient.SetLogger(log.Default())
154+
}
155+
}
135156
// Create output directory if it does not exist
136-
_, err := os.Stat(config.OutDir)
157+
_, err = os.Stat(config.OutDir)
137158
if err != nil {
138159
if os.IsNotExist(err) {
139160
if verbose {
@@ -162,6 +183,55 @@ func runDownload(ctx context.Context, client *czds.Client, config *DownloadConfi
162183
// Shuffle download links to better distribute load on CZDS
163184
downloads = shuffle(downloads)
164185

186+
// Batch pre-download check if enabled
187+
if webhookClient != nil && webhookClient.PrecheckEnabled() {
188+
// Extract zone names from download URLs
189+
zoneNames := make([]string, len(downloads))
190+
for i, dl := range downloads {
191+
zoneNames[i] = extractZoneName(dl)
192+
}
193+
194+
dateStr := time.Now().Format(time.DateOnly)
195+
approvedZones, err := webhookClient.BatchPreDownloadCheck(ctx, zoneNames, dateStr)
196+
197+
if err != nil {
198+
// Fail-open: log error but proceed with all downloads
199+
if !quiet {
200+
fmt.Printf("Warning: Webhook pre-check failed, proceeding with all downloads: %v\n", err)
201+
}
202+
} else {
203+
// Filter downloads to only approved zones
204+
var filteredDownloads []string
205+
skipped := 0
206+
for i, dl := range downloads {
207+
// Reuse the already-extracted zone name
208+
zoneName := zoneNames[i]
209+
if approvedZones[zoneName] {
210+
filteredDownloads = append(filteredDownloads, dl)
211+
} else {
212+
skipped++
213+
if verbose {
214+
fmt.Printf("[%s] SKIPPED: webhook pre-check denied download\n", zoneName)
215+
}
216+
}
217+
}
218+
219+
if !quiet {
220+
fmt.Printf("Webhook pre-check complete: %d approved, %d skipped, %d total\n",
221+
len(filteredDownloads), skipped, len(downloads))
222+
}
223+
224+
downloads = filteredDownloads
225+
226+
if len(downloads) == 0 {
227+
if !quiet {
228+
fmt.Println("No zones to download after webhook filtering")
229+
}
230+
return nil
231+
}
232+
}
233+
}
234+
165235
// Set up channels and sync - buffer channel based on parallel workers for better throughput
166236
loadDone := make(chan bool)
167237
inputChan := make(chan *zoneInfo, int(config.Parallel)*2)
@@ -176,7 +246,7 @@ func runDownload(ctx context.Context, client *czds.Client, config *DownloadConfi
176246
}
177247
for i := uint(0); i < config.Parallel; i++ {
178248
g.Go(func() error {
179-
return worker(ctx, client, config, inputChan, verbose)
249+
return worker(ctx, client, config, webhookClient, inputChan, verbose)
180250
})
181251
}
182252

@@ -225,8 +295,7 @@ func getDownloadLinks(ctx context.Context, client *czds.Client, config *Download
225295
var filteredDownloads []string
226296
for _, link := range downloads {
227297
// Extract zone name from URL (e.g., "com.zone" -> "com")
228-
fileName := path.Base(link)
229-
zoneName := strings.TrimSuffix(fileName, ".zone")
298+
zoneName := extractZoneName(link)
230299
if zoneSet[strings.ToLower(zoneName)] {
231300
filteredDownloads = append(filteredDownloads, link)
232301
}
@@ -236,8 +305,7 @@ func getDownloadLinks(ctx context.Context, client *czds.Client, config *Download
236305
if len(filteredDownloads) < len(zonesToDownload) {
237306
foundZones := make(map[string]bool)
238307
for _, link := range filteredDownloads {
239-
fileName := path.Base(link)
240-
zoneName := strings.TrimSuffix(fileName, ".zone")
308+
zoneName := extractZoneName(link)
241309
foundZones[strings.ToLower(zoneName)] = true
242310
}
243311

@@ -264,16 +332,25 @@ func getDownloadLinks(ctx context.Context, client *czds.Client, config *Download
264332
return downloads, nil
265333
}
266334

335+
// extractZoneName extracts the zone name from a download URL.
336+
// For example, "https://example.com/czds/downloads/com.zone" returns "com".
337+
func extractZoneName(downloadURL string) string {
338+
fileName := path.Base(downloadURL)
339+
return strings.TrimSuffix(fileName, ".zone")
340+
}
341+
267342
// addLinks feeds download tasks to workers through the input channel.
268343
// It signals completion via loadDone channel and handles context cancellation.
269344
func addLinks(ctx context.Context, downloads []string, inputChan chan<- *zoneInfo, loadDone chan<- bool) error {
345+
currentDate := time.Now()
270346
for _, dl := range downloads {
271347
select {
272348
case <-ctx.Done():
273349
return ctx.Err()
274350
case inputChan <- &zoneInfo{
275-
Name: path.Base(dl),
351+
Name: extractZoneName(dl),
276352
Dl: dl,
353+
Date: currentDate,
277354
}:
278355
}
279356
}
@@ -288,7 +365,7 @@ func addLinks(ctx context.Context, downloads []string, inputChan chan<- *zoneInf
288365

289366
// worker is a goroutine that processes zone download tasks from inputChan.
290367
// It downloads zones with retry logic and handles context cancellation gracefully.
291-
func worker(ctx context.Context, client *czds.Client, config *DownloadConfig, inputChan <-chan *zoneInfo, verbose bool) error {
368+
func worker(ctx context.Context, client *czds.Client, config *DownloadConfig, webhookClient *webhook.Client, inputChan <-chan *zoneInfo, verbose bool) error {
292369
for {
293370
select {
294371
case <-ctx.Done():
@@ -308,7 +385,7 @@ func worker(ctx context.Context, client *czds.Client, config *DownloadConfig, in
308385
default:
309386
}
310387

311-
err = zoneDownload(ctx, client, config, zi, verbose)
388+
err = zoneDownload(ctx, client, config, webhookClient, zi, verbose)
312389
if err == nil {
313390
// Success - exit retry loop
314391
break
@@ -318,7 +395,7 @@ func worker(ctx context.Context, client *czds.Client, config *DownloadConfig, in
318395
// don't stop on an error that only affects a single zone
319396
// fixes occasional HTTP 500s from CZDS
320397
if verbose {
321-
fmt.Printf("[%s] Attempt %d/%d failed: %s\n", path.Base(zi.Dl), attempt, config.Retries, err)
398+
fmt.Printf("[%s] Attempt %d/%d failed: %s\n", zi.Name, attempt, config.Retries, err)
322399
}
323400

324401
// If this was the last attempt, don't sleep
@@ -336,7 +413,7 @@ func worker(ctx context.Context, client *czds.Client, config *DownloadConfig, in
336413

337414
// Handle final failure after all retries exhausted
338415
if err != nil {
339-
fmt.Printf("[%s] Max fail count hit after %d attempts; not downloading.\n", path.Base(zi.Dl), config.Retries)
416+
fmt.Printf("[%s] Max fail count hit after %d attempts; not downloading.\n", zi.Name, config.Retries)
340417
// cleanup partial file if it exists
341418
if _, statErr := os.Stat(zi.FullPath); !os.IsNotExist(statErr) {
342419
if removeErr := os.Remove(zi.FullPath); removeErr != nil {
@@ -351,9 +428,9 @@ func worker(ctx context.Context, client *czds.Client, config *DownloadConfig, in
351428

352429
// zoneDownload handles the download of a single zone with local file checks and validation.
353430
// It manages file existence checks, redownload logic, and path safety.
354-
func zoneDownload(ctx context.Context, client *czds.Client, config *DownloadConfig, zi *zoneInfo, verbose bool) error {
431+
func zoneDownload(ctx context.Context, client *czds.Client, config *DownloadConfig, webhookClient *webhook.Client, zi *zoneInfo, verbose bool) error {
355432
if verbose {
356-
fmt.Printf("Downloading '%s'\n", zi.Dl)
433+
fmt.Printf("[%s] Checking download requirements...\n", zi.Name)
357434
}
358435

359436
info, err := client.GetDownloadInfoWithContext(ctx, zi.Dl)
@@ -391,9 +468,9 @@ func zoneDownload(ctx context.Context, client *czds.Client, config *DownloadConf
391468
localFileInfo, err := os.Stat(zi.FullPath)
392469
if config.Force {
393470
if verbose {
394-
fmt.Printf("Forcing download of '%s'\n", zi.Dl)
471+
fmt.Printf("[%s] Downloading: forced redownload\n", localFileName)
395472
}
396-
return downloadTime(ctx, client, zi, info.ContentLength, config.Quiet, config.Progress)
473+
return downloadTime(ctx, client, webhookClient, zi, info.ContentLength, config.Quiet, config.Progress, verbose)
397474
}
398475

399476
// check if local file already exists
@@ -402,29 +479,42 @@ func zoneDownload(ctx context.Context, client *czds.Client, config *DownloadConf
402479
if localFileInfo.Size() != info.ContentLength {
403480
// size differs, redownload
404481
if verbose {
405-
fmt.Printf("Size of local file (%d) differs from remote (%d), redownloading %s\n",
406-
localFileInfo.Size(), info.ContentLength, localFileName)
482+
fmt.Printf("[%s] Downloading: local size (%d bytes) differs from remote (%d bytes)\n",
483+
localFileName, localFileInfo.Size(), info.ContentLength)
407484
}
408-
return downloadTime(ctx, client, zi, info.ContentLength, config.Quiet, config.Progress)
485+
return downloadTime(ctx, client, webhookClient, zi, info.ContentLength, config.Quiet, config.Progress, verbose)
409486
}
410487
// check local file modification date
411488
if localFileInfo.ModTime().Before(info.LastModified) {
412489
// remote file is newer, redownload
413490
if verbose {
414-
fmt.Println("Remote file is newer than local, redownloading")
491+
fmt.Printf("[%s] Downloading: remote file is newer (local: %s, remote: %s)\n",
492+
localFileName, localFileInfo.ModTime().Format(time.RFC3339), info.LastModified.Format(time.RFC3339))
415493
}
416-
return downloadTime(ctx, client, zi, info.ContentLength, config.Quiet, config.Progress)
494+
return downloadTime(ctx, client, webhookClient, zi, info.ContentLength, config.Quiet, config.Progress, verbose)
417495
}
418496
// local copy is good, skip download
419497
if verbose {
420-
fmt.Printf("Local file '%s' matched remote, skipping\n", localFileName)
498+
fmt.Printf("[%s] SKIPPED: local file is up-to-date (size: %d bytes, modified: %s)\n",
499+
localFileName, localFileInfo.Size(), localFileInfo.ModTime().Format(time.RFC3339))
421500
}
422501
return nil
423502
}
424503

425504
if os.IsNotExist(err) {
426505
// file does not exist, download
427-
return downloadTime(ctx, client, zi, info.ContentLength, config.Quiet, config.Progress)
506+
if verbose {
507+
fmt.Printf("[%s] Downloading: file does not exist locally\n", localFileName)
508+
}
509+
return downloadTime(ctx, client, webhookClient, zi, info.ContentLength, config.Quiet, config.Progress, verbose)
510+
}
511+
512+
// File exists but no redownload flag - skip
513+
if err == nil {
514+
if verbose {
515+
fmt.Printf("[%s] SKIPPED: file exists locally\n", localFileName)
516+
}
517+
return nil
428518
}
429519

430520
return err
@@ -433,7 +523,7 @@ func zoneDownload(ctx context.Context, client *czds.Client, config *DownloadConf
433523
// downloadTime downloads a zone file and reports the time taken for the operation.
434524
// It provides timing feedback and progress reporting unless quiet mode is enabled.
435525
// Uses atomic file operations - downloads to a temporary file first, then renames on success.
436-
func downloadTime(ctx context.Context, client *czds.Client, zi *zoneInfo, contentLength int64, quiet, showProgress bool) error {
526+
func downloadTime(ctx context.Context, client *czds.Client, webhookClient *webhook.Client, zi *zoneInfo, contentLength int64, quiet, showProgress, verbose bool) error {
437527
// Create temporary file in same directory for atomic operation
438528
tempPath := zi.FullPath + ".tmp"
439529
file, err := os.Create(tempPath)
@@ -463,6 +553,30 @@ func downloadTime(ctx context.Context, client *czds.Client, zi *zoneInfo, conten
463553
if removeErr := os.Remove(tempPath); removeErr != nil && !quiet {
464554
fmt.Printf("Error removing temp file after rename failure %s: %v\n", tempPath, removeErr)
465555
}
556+
} else {
557+
// Successfully downloaded and renamed - send notification if enabled
558+
if webhookClient != nil && webhookClient.NotifyEnabled() {
559+
if verbose {
560+
fmt.Printf("[%s] Sending webhook notification...\n", zi.Name)
561+
}
562+
dateStr := zi.Date.Format(time.DateOnly)
563+
result, err := webhookClient.PostDownloadNotify(ctx, zi.Name, zi.FullPath, dateStr)
564+
if err != nil {
565+
if !quiet {
566+
fmt.Printf("[%s] ERROR: Webhook notification failed (network/connection error): %v\n", zi.Name, err)
567+
}
568+
} else if result != nil {
569+
if result.Status == "error" {
570+
if !quiet {
571+
fmt.Printf("[%s] ERROR: Webhook returned error status: %s\n", zi.Name, result.Message)
572+
}
573+
} else if verbose {
574+
fmt.Printf("[%s] Webhook notification successful (status: %s)\n", zi.Name, result.Status)
575+
}
576+
} else if verbose {
577+
fmt.Printf("[%s] Webhook notification sent (no response body)\n", zi.Name)
578+
}
579+
}
466580
}
467581
}
468582
}()
@@ -527,8 +641,7 @@ func pruneLinks(downloads []string, exclude string) []string {
527641
newlist := make([]string, 0, len(downloads))
528642
for _, u := range downloads {
529643
// Extract zone name from URL (e.g., "com.zone" -> "com")
530-
fileName := path.Base(u)
531-
zoneName := strings.TrimSuffix(fileName, ".zone")
644+
zoneName := extractZoneName(u)
532645

533646
// O(1) lookup instead of O(n*m) string suffix matching
534647
if !excludeMap[strings.ToLower(zoneName)] {

0 commit comments

Comments
 (0)