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

@@ -75,6 +75,10 @@ func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds
}
func OneTimeDownloadExpiry() int64 {
return oneTimeDownloadExpiry
}
func UploadRoot() string {
return uploadRoot
}
@@ -185,12 +189,7 @@ func BoxSummary(boxID string) (models.BoxSummary, error) {
func ListFiles(boxID string) ([]models.BoxFile, error) {
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
files := make([]models.BoxFile, 0, len(manifest.Files))
for _, file := range manifest.Files {
files = append(files, DecorateFile(boxID, file))
}
return files, nil
return DecorateFiles(boxID, manifest.Files), nil
}
return listCompletedFilesFromDisk(boxID)
@@ -513,6 +512,14 @@ func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
return file
}
func DecorateFiles(boxID string, files []models.BoxFile) []models.BoxFile {
decorated := make([]models.BoxFile, 0, len(files))
for _, file := range files {
decorated = append(decorated, DecorateFile(boxID, file))
}
return decorated
}
func IconForMimeType(mimeType string, filename string) string {
extension := strings.ToLower(filepath.Ext(filename))
@@ -645,15 +652,13 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
}
seconds := manifest.RetentionSecs
if seconds <= 0 {
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
if manifest.OneTimeDownload && seconds <= 0 {
if seconds <= 0 {
return
}
@@ -663,8 +668,6 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
}
}
// seconds is already handled above
// Retention starts after uploads settle so slow or very large uploads do
// not expire before users get a real chance to open the box.
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)

View File

@@ -47,7 +47,11 @@ func TestStartRetentionBeginsWhenEveryFileIsTerminal(t *testing.T) {
}
}
func TestStartRetentionSkipsOneTimeDownload(t *testing.T) {
func TestStartRetentionUsesConfiguredOneTimeDownloadExpiry(t *testing.T) {
restoreExpiry := OneTimeDownloadExpiry()
defer SetOneTimeDownloadExpiry(restoreExpiry)
SetOneTimeDownloadExpiry(30)
manifest := models.BoxManifest{
RetentionSecs: 10,
OneTimeDownload: true,
@@ -56,11 +60,38 @@ func TestStartRetentionSkipsOneTimeDownload(t *testing.T) {
{ID: "two", Status: models.FileStatusReady},
},
}
before := time.Now().UTC()
startRetentionIfTerminalUnlocked(&manifest)
if manifest.ExpiresAt.IsZero() {
t.Fatal("expected one-time download expiry to start from configured expiry")
}
if manifest.ExpiresAt.Before(before.Add(29 * time.Second)) {
t.Fatalf("expected configured one-time expiry, got %s", manifest.ExpiresAt)
}
if manifest.ExpiresAt.After(before.Add(31 * time.Second)) {
t.Fatalf("expected configured one-time expiry near 30s, got %s", manifest.ExpiresAt)
}
}
func TestStartRetentionSkipsOneTimeDownloadWhenExpiryZero(t *testing.T) {
restoreExpiry := OneTimeDownloadExpiry()
defer SetOneTimeDownloadExpiry(restoreExpiry)
SetOneTimeDownloadExpiry(0)
manifest := models.BoxManifest{
RetentionSecs: 10,
OneTimeDownload: true,
Files: []models.BoxFile{
{ID: "one", Status: models.FileStatusReady},
},
}
startRetentionIfTerminalUnlocked(&manifest)
if !manifest.ExpiresAt.IsZero() {
t.Fatalf("expected one-time download box to avoid retention expiry, got %s", manifest.ExpiresAt)
t.Fatalf("expected zero one-time expiry to keep expiry unset, got %s", manifest.ExpiresAt)
}
}
@@ -115,6 +146,32 @@ func TestListFilesSkipsSymlinks(t *testing.T) {
}
}
func TestThumbnailTasksSkipOneTimeDownloadBoxes(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "0123456789abcdef0123456789abcdef"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if err := WriteManifest(boxID, models.BoxManifest{
OneTimeDownload: true,
Files: []models.BoxFile{{
ID: "0123456789abcdef",
Name: "image.png",
MimeType: "image/png",
Status: models.FileStatusReady,
}},
}); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
if tasks := collectBoxThumbnailTasks(boxID, 10); len(tasks) != 0 {
t.Fatalf("expected no thumbnail tasks for one-time box, got %#v", tasks)
}
}
func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)

View File

@@ -95,7 +95,7 @@ func collectBoxThumbnailTasks(boxID string, remaining int) []thumbnailTask {
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil || IsExpired(manifest) {
if err != nil || IsExpired(manifest) || manifest.OneTimeDownload {
return nil
}