check / check (push) Successful in 3m31s
A database target's rotation (none, monthly, daily or hourly) puts the UTC period of each event's receive time in its archive file name, so each file holds exactly its period's events. It is on the new webhook page, the add target form and the target edit form, and shown in the target list. Renames move every one of a target's files and move them back if one fails. The sweep prunes every file, one at a time under the target's lock, and deletes a rotated file it leaves empty. Download lists the files, then opens one at a time, oldest first, finding each again under the target's current names, and gives each row its period. The target list names the current file and totals the size of all of them. 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/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
|
|
}
|