check / check (push) Canceled after 0s
Replaces #559, which conflicted. Model: opus-5-5
148 lines
4.1 KiB
Go
148 lines
4.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"sneak.berlin/go/webhooker/internal/archive"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// 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 archive.Export.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="`+archive.ExportFileName(
|
|
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 the rename lock, 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 the lock 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, *archive.Export, bool) {
|
|
h.archives.RenameLock().Lock()
|
|
defer h.archives.RenameLock().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 := archive.NewExport(
|
|
archive.Path(h.dbMgr, &webhook, target),
|
|
h.archives.RenameLock(),
|
|
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 archive.Path gives a
|
|
// database target under the names stored for it and its webhook now.
|
|
// Its caller holds the rename lock. 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 archive.Path(h.dbMgr, &webhook, &target), nil
|
|
}
|