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.
4345type 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.
269344func 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