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:
2026-04-30 04:24:49 +03:00
parent 7d70a0c2ed
commit a729b641b2
14 changed files with 483 additions and 72 deletions

View File

@@ -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")