package handlers import ( "context" "errors" "net/http" "time" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) // downloadWriteTimeout is how long one write of a download may wait // for a client that has stopped reading. const downloadWriteTimeout = 60 * time.Second // HandleTargetDownload serves a database target's archive as one // gzipped JSON file, named for the webhook, the target and the time; // see delivery.ArchiveExport.WriteGzipJSON for what it holds. Other // target types have no archive and are a 404. // // A download runs for as long as the client keeps reading: it reads // under a context the request limit does not cancel, and gives each // write its own deadline in place of the server's write timeout. It // stops when a write fails. func (h *Handlers) HandleTargetDownload() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := context.WithoutCancel(r.Context()) webhook, target, export, ok := h.openTargetArchive(ctx, w, r) if !ok { return } defer func() { _ = export.Close() }() now := time.Now() w.Header().Set("Content-Type", "application/gzip") w.Header().Set( "Content-Disposition", `attachment; filename="`+delivery.ArchiveExportFileName( webhook.Name, target.Name, now, )+`"`, ) err := export.WriteGzipJSON( ctx, downloadWriter{w: w, rc: http.NewResponseController(w)}, &webhook, target, now, ) if err != nil { h.log.Error( "failed to export archive", "target_id", target.ID, "error", err, ) // The 200 has gone out. Aborting the connection is what // tells the client the file is incomplete. panic(http.ErrAbortHandler) } } } // downloadWriter writes a download to the client, giving each write // downloadWriteTimeout to finish. type downloadWriter struct { w http.ResponseWriter rc *http.ResponseController } func (d downloadWriter) Write(b []byte) (int, error) { // A writer that has no write deadline, such as a test's recorder, // answers http.ErrNotSupported and needs none extended. err := d.rc.SetWriteDeadline(time.Now().Add(downloadWriteTimeout)) if err != nil && !errors.Is(err, http.ErrNotSupported) { return 0, err } return d.w.Write(b) } // openTargetArchive opens the archive of the request's database target // for export, with its reads under ctx. It reports false once it has // written the response. // // It holds renameMu, which every archive rename runs under, while it // reads the stored names and opens the file, so the file it opens is // the one those names give. It lets go before the export is streamed: // once the file is open, a rename does not affect the export. func (h *Handlers) openTargetArchive( ctx context.Context, w http.ResponseWriter, r *http.Request, ) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) { h.renameMu.Lock() defer h.renameMu.Unlock() webhook, target, ok := h.ownedTarget(w, r) if !ok { return database.Webhook{}, nil, nil, false } if target.Type != database.TargetTypeDatabase { h.renderError(w, r, http.StatusNotFound) return database.Webhook{}, nil, nil, false } export, err := delivery.OpenArchiveExport( ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log, ) if err != nil { h.serverError(w, r, "failed to open archive for export", err) return database.Webhook{}, nil, nil, false } return webhook, target, export, true }