feat(one-time-downloads): add expiry and retry configuration
Introduce new environment variables to control the behavior of one-time download boxes: - `WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS`: Sets the lifetime of a one-time box after uploads are complete. - `WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE`: Determines whether a box remains available if the ZIP creation or transfer fails. To support these settings, the ZIP delivery process was refactored to use a temporary file. This ensures that a one-time box is only marked as consumed after the file has been successfully transferred to the client, preventing data loss on network interruptions. Additionally, added a `DecorateFiles` helper in the box store to reduce code duplication.
This commit is contained in:
@@ -373,6 +373,7 @@ func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ func (app *App) handleShowBox(ctx *gin.Context) {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
files = stripOneTimeThumbnailState(files)
|
||||
}
|
||||
|
||||
downloadAll := "/box/" + boxID + "/download"
|
||||
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
|
||||
@@ -148,15 +151,24 @@ func (app *App) handleBoxStatus(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
manifest, _, ok := app.authorizeBoxRequest(ctx, boxID, false)
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
||||
return
|
||||
var files []models.BoxFile
|
||||
if hasManifest && manifestFilesReady(manifest.Files) {
|
||||
files = boxstore.DecorateFiles(boxID, manifest.Files)
|
||||
} else {
|
||||
var err error
|
||||
files, err = boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
files = stripOneTimeThumbnailState(files)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
|
||||
@@ -216,12 +228,6 @@ func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
|
||||
return
|
||||
}
|
||||
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
@@ -231,41 +237,90 @@ func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
|
||||
ctx.String(http.StatusConflict, "Box is not ready yet")
|
||||
return
|
||||
}
|
||||
|
||||
if app.config.OneTimeDownloadRetryOnFailure {
|
||||
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
|
||||
return
|
||||
}
|
||||
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
|
||||
return
|
||||
}
|
||||
if !app.writeBoxZip(ctx, boxID, files) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
return
|
||||
}
|
||||
boxstore.DeleteBox(boxID)
|
||||
}
|
||||
|
||||
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
|
||||
writeBoxZipHeaders(ctx, boxID)
|
||||
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
|
||||
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
|
||||
return
|
||||
}
|
||||
tempPath := tempZip.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
|
||||
tempZip.Close()
|
||||
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
|
||||
return
|
||||
}
|
||||
if _, err := tempZip.Seek(0, 0); err != nil {
|
||||
tempZip.Close()
|
||||
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
|
||||
return
|
||||
}
|
||||
|
||||
writeBoxZipHeaders(ctx, boxID)
|
||||
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
|
||||
tempZip.Close()
|
||||
return
|
||||
}
|
||||
if err := tempZip.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
return
|
||||
}
|
||||
boxstore.DeleteBox(boxID)
|
||||
}
|
||||
|
||||
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
|
||||
ctx.Header("Content-Type", "application/zip")
|
||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
||||
}
|
||||
|
||||
zipWriter := zip.NewWriter(ctx.Writer)
|
||||
zipClosed := false
|
||||
defer func() {
|
||||
if !zipClosed {
|
||||
zipWriter.Close()
|
||||
}
|
||||
}()
|
||||
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
|
||||
zipWriter := zip.NewWriter(destination)
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsComplete {
|
||||
continue
|
||||
}
|
||||
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return false
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
zipClosed = true
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return false
|
||||
return err
|
||||
}
|
||||
zipClosed = true
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
|
||||
func oneTimeDownloadLock(boxID string) *sync.Mutex {
|
||||
@@ -287,6 +342,31 @@ func allFilesComplete(files []models.BoxFile) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func manifestFilesReady(files []models.BoxFile) bool {
|
||||
if len(files) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.Status != models.FileStatusReady {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
|
||||
stripped := make([]models.BoxFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
file.ThumbnailPath = nil
|
||||
file.ThumbnailURL = ""
|
||||
if file.ThumbnailStatus == "" {
|
||||
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
|
||||
}
|
||||
stripped = append(stripped, file)
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
func (app *App) handleDownloadFile(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
|
||||
@@ -595,6 +675,15 @@ func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bo
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if manifest.OneTimeDownload && manifest.Consumed {
|
||||
if wantsHTML {
|
||||
ctx.String(http.StatusGone, "Box already consumed")
|
||||
} else {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
|
||||
}
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
|
||||
if wantsHTML {
|
||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
|
||||
|
||||
219
lib/server/one_time_test.go
Normal file
219
lib/server/one_time_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
const oneTimeTestBoxID = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestOneTimeDownloadNotReadyDoesNotConsume(t *testing.T) {
|
||||
app := setupOneTimeDownloadTest(t, false)
|
||||
writeOneTimeManifest(t, models.FileStatusWork, false)
|
||||
|
||||
response := performOneTimeDownload(app)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("expected not-ready download to return 409, got %d", response.Code)
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if manifest.Consumed {
|
||||
t.Fatal("expected not-ready box to remain unconsumed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneTimeDownloadReadyConsumesAndDeletes(t *testing.T) {
|
||||
app := setupOneTimeDownloadTest(t, false)
|
||||
writeOneTimeManifest(t, models.FileStatusReady, true)
|
||||
|
||||
response := performOneTimeDownload(app)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected ready download to return 200, got %d", response.Code)
|
||||
}
|
||||
if _, err := zip.NewReader(bytes.NewReader(response.Body.Bytes()), int64(response.Body.Len())); err != nil {
|
||||
t.Fatalf("expected valid zip body: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(oneTimeTestBoxID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected consumed box to be deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneTimeDownloadWriterFailureConsumesByDefault(t *testing.T) {
|
||||
app := setupOneTimeDownloadTest(t, false)
|
||||
writeOneTimeManifest(t, models.FileStatusReady, false)
|
||||
|
||||
response := performOneTimeDownload(app)
|
||||
if response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected failed ZIP to return 500, got %d", response.Code)
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(oneTimeTestBoxID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected failed ZIP to delete box by default, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneTimeDownloadWriterFailureCanRemainRetryable(t *testing.T) {
|
||||
app := setupOneTimeDownloadTest(t, true)
|
||||
writeOneTimeManifest(t, models.FileStatusReady, false)
|
||||
|
||||
response := performOneTimeDownload(app)
|
||||
if response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected failed ZIP to return 500, got %d", response.Code)
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if manifest.Consumed {
|
||||
t.Fatal("expected failed retryable ZIP to remain unconsumed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneTimeDownloadSecondAccessAfterConsumeIsGone(t *testing.T) {
|
||||
app := setupOneTimeDownloadTest(t, false)
|
||||
writeOneTimeManifest(t, models.FileStatusReady, true)
|
||||
|
||||
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
response := performOneTimeDownload(app)
|
||||
if response.Code != http.StatusGone {
|
||||
t.Fatalf("expected consumed download to return 410, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneTimeStatusStripsThumbnailPath(t *testing.T) {
|
||||
app := setupOneTimeDownloadTest(t, false)
|
||||
app.config.APIEnabled = true
|
||||
writeOneTimeManifest(t, models.FileStatusReady, true)
|
||||
|
||||
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
thumbnailPath := "/box/" + oneTimeTestBoxID + "/thumbnails/0123456789abcdef"
|
||||
manifest.Files[0].ThumbnailPath = &thumbnailPath
|
||||
manifest.Files[0].ThumbnailStatus = models.ThumbnailStatusReady
|
||||
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
response := performOneTimeStatus(app)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected status to return 200, got %d", response.Code)
|
||||
}
|
||||
var payload struct {
|
||||
Files []models.BoxFile `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if len(payload.Files) != 1 {
|
||||
t.Fatalf("expected one file, got %#v", payload.Files)
|
||||
}
|
||||
if payload.Files[0].ThumbnailPath != nil {
|
||||
t.Fatalf("expected one-time status to strip thumbnail path, got %q", *payload.Files[0].ThumbnailPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConfigAppliesDBOneTimeExpiryOverride(t *testing.T) {
|
||||
restoreExpiry := boxstore.OneTimeDownloadExpiry()
|
||||
defer boxstore.SetOneTimeDownloadExpiry(restoreExpiry)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if err := cfg.ApplyOverrides(map[string]string{config.SettingOneTimeDownloadExpirySecs: "42"}); err != nil {
|
||||
t.Fatalf("ApplyOverrides returned error: %v", err)
|
||||
}
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
if got := boxstore.OneTimeDownloadExpiry(); got != 42 {
|
||||
t.Fatalf("expected runtime one-time expiry to be updated from config, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func setupOneTimeDownloadTest(t *testing.T, retryOnFailure bool) *App {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
restoreUploadRoot := boxstore.UploadRoot()
|
||||
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
|
||||
boxstore.SetUploadRoot(t.TempDir())
|
||||
|
||||
return &App{config: &config.Config{
|
||||
ZipDownloadsEnabled: true,
|
||||
OneTimeDownloadRetryOnFailure: retryOnFailure,
|
||||
}}
|
||||
}
|
||||
|
||||
func writeOneTimeManifest(t *testing.T, status string, createFile bool) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(boxstore.BoxPath(oneTimeTestBoxID), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
if createFile {
|
||||
path, ok := boxstore.SafeBoxFilePath(oneTimeTestBoxID, "file.txt")
|
||||
if !ok {
|
||||
t.Fatal("SafeBoxFilePath rejected test file")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
manifest := models.BoxManifest{
|
||||
Files: []models.BoxFile{{
|
||||
ID: "0123456789abcdef",
|
||||
Name: "file.txt",
|
||||
Size: 5,
|
||||
MimeType: "text/plain",
|
||||
Status: status,
|
||||
}},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
OneTimeDownload: true,
|
||||
}
|
||||
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func performOneTimeDownload(app *App) *httptest.ResponseRecorder {
|
||||
router := gin.New()
|
||||
router.GET("/box/:id/download", app.handleDownloadBox)
|
||||
request := httptest.NewRequest(http.MethodGet, "/box/"+oneTimeTestBoxID+"/download", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func performOneTimeStatus(app *App) *httptest.ResponseRecorder {
|
||||
router := gin.New()
|
||||
router.GET("/box/:id/status", app.handleBoxStatus)
|
||||
request := httptest.NewRequest(http.MethodGet, "/box/"+oneTimeTestBoxID+"/status", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@@ -28,8 +28,7 @@ func Run(addr string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
boxstore.SetUploadRoot(cfg.UploadsDir)
|
||||
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
store, err := metastore.Open(cfg.DBDir)
|
||||
if err != nil {
|
||||
@@ -44,6 +43,7 @@ func Run(addr string) error {
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("apply settings overrides: %w", err)
|
||||
}
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
|
||||
if err != nil {
|
||||
@@ -83,3 +83,8 @@ func Run(addr string) error {
|
||||
|
||||
return router.Run(addr)
|
||||
}
|
||||
|
||||
func applyBoxstoreRuntimeConfig(cfg *config.Config) {
|
||||
boxstore.SetUploadRoot(cfg.UploadsDir)
|
||||
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user