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.listTargetArchive(w, r) if !ok { return } 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) } // listTargetArchive lists the archive files of the request's database // target for export. 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 lists the files, so the files it lists // are the ones those names give. It lets go before the export is // streamed, which takes renameMu again for each file only while it // finds the file under the names stored then and opens it. func (h *Handlers) listTargetArchive( 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.NewArchiveExport( delivery.ArchivePath(h.dbMgr, &webhook, target), &h.renameMu, func() (string, error) { return h.storedArchivePath(webhook.ID, target.ID) }, h.log, ) if err != nil { h.serverError(w, r, "failed to list archive for export", err) return database.Webhook{}, nil, nil, false } return webhook, target, export, true } // storedArchivePath returns the path delivery.ArchivePath gives a // database target under the names stored for it and its webhook now. // Its caller holds renameMu. A webhook or target deleted since is still // found, since deleting one leaves its archive files under their names. func (h *Handlers) storedArchivePath( webhookID, targetID string, ) (string, error) { var webhook database.Webhook err := h.db.DB().Unscoped().First(&webhook, "id = ?", webhookID).Error if err != nil { return "", err } var target database.Target err = h.db.DB().Unscoped().First(&target, "id = ?", targetID).Error if err != nil { return "", err } return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil }