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

@@ -3,6 +3,8 @@ WARPBOX_GUEST_UPLOADS_ENABLED=true
WARPBOX_API_ENABLED=true WARPBOX_API_ENABLED=true
WARPBOX_ZIP_DOWNLOADS_ENABLED=true WARPBOX_ZIP_DOWNLOADS_ENABLED=true
WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false
# Storage and expiry limits (in MB) # Storage and expiry limits (in MB)
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048

View File

@@ -47,6 +47,8 @@ ENV WARPBOX_DATA_DIR=/app/data \
WARPBOX_API_ENABLED=true \ WARPBOX_API_ENABLED=true \
WARPBOX_ZIP_DOWNLOADS_ENABLED=true \ WARPBOX_ZIP_DOWNLOADS_ENABLED=true \
WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true \ WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true \
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
WARPBOX_ADMIN_ENABLED=true \ WARPBOX_ADMIN_ENABLED=true \
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \ WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \ WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \

View File

@@ -102,6 +102,8 @@ settings remain environment controlled.
| `WARPBOX_API_ENABLED` | `true` | Enables JSON/upload endpoints used by the UI. | | `WARPBOX_API_ENABLED` | `true` | Enables JSON/upload endpoints used by the UI. |
| `WARPBOX_ZIP_DOWNLOADS_ENABLED` | `true` | Enables ZIP downloads. | | `WARPBOX_ZIP_DOWNLOADS_ENABLED` | `true` | Enables ZIP downloads. |
| `WARPBOX_ONE_TIME_DOWNLOADS_ENABLED` | `true` | Enables one-time download boxes. | | `WARPBOX_ONE_TIME_DOWNLOADS_ENABLED` | `true` | Enables one-time download boxes. |
| `WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS` | `604800` | One-time box lifetime after uploads finish; `0` disables timed expiry. |
| `WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE` | `false` | Keeps one-time boxes alive when ZIP build/send fails before completion. |
| `WARPBOX_RENEW_ON_ACCESS_ENABLED` | `false` | Renews expiring boxes on access. | | `WARPBOX_RENEW_ON_ACCESS_ENABLED` | `false` | Renews expiring boxes on access. |
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. | | `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. | | `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
@@ -121,6 +123,7 @@ Example:
```bash ```bash
WARPBOX_ADMIN_PASSWORD='change-me' \ WARPBOX_ADMIN_PASSWORD='change-me' \
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
WARPBOX_BOX_POLL_INTERVAL_MS=2000 \ WARPBOX_BOX_POLL_INTERVAL_MS=2000 \
WARPBOX_THUMBNAIL_BATCH_SIZE=20 \ WARPBOX_THUMBNAIL_BATCH_SIZE=20 \
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \ WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \

View File

@@ -75,6 +75,10 @@ func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds oneTimeDownloadExpiry = seconds
} }
func OneTimeDownloadExpiry() int64 {
return oneTimeDownloadExpiry
}
func UploadRoot() string { func UploadRoot() string {
return uploadRoot return uploadRoot
} }
@@ -185,12 +189,7 @@ func BoxSummary(boxID string) (models.BoxSummary, error) {
func ListFiles(boxID string) ([]models.BoxFile, error) { func ListFiles(boxID string) ([]models.BoxFile, error) {
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 { if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
files := make([]models.BoxFile, 0, len(manifest.Files)) return DecorateFiles(boxID, manifest.Files), nil
for _, file := range manifest.Files {
files = append(files, DecorateFile(boxID, file))
}
return files, nil
} }
return listCompletedFilesFromDisk(boxID) return listCompletedFilesFromDisk(boxID)
@@ -513,6 +512,14 @@ func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
return file 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 { func IconForMimeType(mimeType string, filename string) string {
extension := strings.ToLower(filepath.Ext(filename)) extension := strings.ToLower(filepath.Ext(filename))
@@ -645,15 +652,13 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
} }
seconds := manifest.RetentionSecs seconds := manifest.RetentionSecs
if seconds <= 0 { if manifest.OneTimeDownload {
if manifest.OneTimeDownload { seconds = oneTimeDownloadExpiry
seconds = oneTimeDownloadExpiry } else if seconds <= 0 {
} else { seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
} }
if manifest.OneTimeDownload && seconds <= 0 { if seconds <= 0 {
return 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 // Retention starts after uploads settle so slow or very large uploads do
// not expire before users get a real chance to open the box. // not expire before users get a real chance to open the box.
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second) 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{ manifest := models.BoxManifest{
RetentionSecs: 10, RetentionSecs: 10,
OneTimeDownload: true, OneTimeDownload: true,
@@ -56,11 +60,38 @@ func TestStartRetentionSkipsOneTimeDownload(t *testing.T) {
{ID: "two", Status: models.FileStatusReady}, {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) startRetentionIfTerminalUnlocked(&manifest)
if !manifest.ExpiresAt.IsZero() { 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) { func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
restoreUploadRoot := UploadRoot() restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot) defer SetUploadRoot(restoreUploadRoot)

View File

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

View File

@@ -31,6 +31,7 @@ const (
SettingZipDownloadsEnabled = "zip_downloads_enabled" SettingZipDownloadsEnabled = "zip_downloads_enabled"
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled" SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds" SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
SettingRenewOnAccessEnabled = "renew_on_access_enabled" SettingRenewOnAccessEnabled = "renew_on_access_enabled"
SettingRenewOnDownloadEnabled = "renew_on_download_enabled" SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds" SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
@@ -83,13 +84,14 @@ type Config struct {
AdminCookieSecure bool AdminCookieSecure bool
AllowAdminSettingsOverride bool AllowAdminSettingsOverride bool
GuestUploadsEnabled bool GuestUploadsEnabled bool
APIEnabled bool APIEnabled bool
ZipDownloadsEnabled bool ZipDownloadsEnabled bool
OneTimeDownloadsEnabled bool OneTimeDownloadsEnabled bool
OneTimeDownloadExpirySeconds int64 OneTimeDownloadExpirySeconds int64
RenewOnAccessEnabled bool OneTimeDownloadRetryOnFailure bool
RenewOnDownloadEnabled bool RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64 DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64 MaxGuestExpirySeconds int64
@@ -113,6 +115,7 @@ var Definitions = []SettingDefinition{
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
@@ -129,23 +132,24 @@ var Definitions = []SettingDefinition{
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
DataDir: "./data", DataDir: "./data",
AdminUsername: "admin", AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto, AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true, AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true, GuestUploadsEnabled: true,
APIEnabled: true, APIEnabled: true,
ZipDownloadsEnabled: true, ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true, OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60, OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
DefaultGuestExpirySeconds: 10, OneTimeDownloadRetryOnFailure: false,
MaxGuestExpirySeconds: 48 * 60 * 60, DefaultGuestExpirySeconds: 10,
SessionTTLSeconds: 24 * 60 * 60, MaxGuestExpirySeconds: 48 * 60 * 60,
BoxPollIntervalMS: 5000, SessionTTLSeconds: 24 * 60 * 60,
ThumbnailBatchSize: 10, BoxPollIntervalMS: 5000,
ThumbnailIntervalSeconds: 30, ThumbnailBatchSize: 10,
sources: make(map[string]Source), ThumbnailIntervalSeconds: 30,
values: make(map[string]string), sources: make(map[string]Source),
values: make(map[string]string),
} }
cfg.captureDefaults() cfg.captureDefaults()
@@ -185,6 +189,7 @@ func Load() (*Config, error) {
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled}, {SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled}, {SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled}, {SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled}, {SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled}, {SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
} }
@@ -365,6 +370,7 @@ func (cfg *Config) captureDefaults() {
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault) cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault) cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault) cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault) cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault) cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault) cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)

View File

@@ -38,6 +38,7 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100") t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000") t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root") t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
@@ -59,6 +60,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if cfg.AdminUsername != "root" { if cfg.AdminUsername != "root" {
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername) t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
} }
if !cfg.OneTimeDownloadRetryOnFailure {
t.Fatal("expected one-time retry-on-failure env override to be applied")
}
if cfg.Source(SettingAPIEnabled) != SourceEnv { if cfg.Source(SettingAPIEnabled) != SourceEnv {
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled)) t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
} }
@@ -160,6 +164,7 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_API_ENABLED", "WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED", "WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
"WARPBOX_RENEW_ON_ACCESS_ENABLED", "WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",

View File

@@ -373,6 +373,7 @@ func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
return return
} }
} }
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings") ctx.Redirect(http.StatusSeeOther, "/admin/settings")
} }

View File

@@ -55,6 +55,9 @@ func (app *App) handleShowBox(ctx *gin.Context) {
ctx.String(http.StatusNotFound, "Box not found") ctx.String(http.StatusNotFound, "Box not found")
return return
} }
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
downloadAll := "/box/" + boxID + "/download" downloadAll := "/box/" + boxID + "/download"
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip { if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
@@ -148,15 +151,24 @@ func (app *App) handleBoxStatus(ctx *gin.Context) {
return return
} }
manifest, _, ok := app.authorizeBoxRequest(ctx, boxID, false) manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
if !ok { if !ok {
return return
} }
files, err := boxstore.ListFiles(boxID) var files []models.BoxFile
if err != nil { if hasManifest && manifestFilesReady(manifest.Files) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"}) files = boxstore.DecorateFiles(boxID, manifest.Files)
return } 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}) 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 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) files, err := boxstore.ListFiles(boxID)
if err != nil { if err != nil {
ctx.String(http.StatusNotFound, "Box not found") 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") ctx.String(http.StatusConflict, "Box is not ready yet")
return 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) { if !app.writeBoxZip(ctx, boxID, files) {
boxstore.DeleteBox(boxID)
return return
} }
boxstore.DeleteBox(boxID) boxstore.DeleteBox(boxID)
} }
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool { 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-Type", "application/zip")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID)) ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
}
zipWriter := zip.NewWriter(ctx.Writer) func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
zipClosed := false zipWriter := zip.NewWriter(destination)
defer func() {
if !zipClosed {
zipWriter.Close()
}
}()
for _, file := range files { for _, file := range files {
if !file.IsComplete { if !file.IsComplete {
continue continue
} }
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil { if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
ctx.Status(http.StatusInternalServerError) return err
return false
} }
} }
if err := zipWriter.Close(); err != nil { if err := zipWriter.Close(); err != nil {
zipClosed = true return err
ctx.Status(http.StatusInternalServerError)
return false
} }
zipClosed = true return nil
return true
} }
func oneTimeDownloadLock(boxID string) *sync.Mutex { func oneTimeDownloadLock(boxID string) *sync.Mutex {
@@ -287,6 +342,31 @@ func allFilesComplete(files []models.BoxFile) bool {
return true 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) { func (app *App) handleDownloadFile(ctx *gin.Context) {
boxID := ctx.Param("id") boxID := ctx.Param("id")
filename, ok := helpers.SafeFilename(ctx.Param("filename")) 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 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 boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
if wantsHTML { if wantsHTML {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login") ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")

219
lib/server/one_time_test.go Normal file
View 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
}

View File

@@ -28,8 +28,7 @@ func Run(addr string) error {
return err return err
} }
boxstore.SetUploadRoot(cfg.UploadsDir) applyBoxstoreRuntimeConfig(cfg)
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
store, err := metastore.Open(cfg.DBDir) store, err := metastore.Open(cfg.DBDir)
if err != nil { if err != nil {
@@ -44,6 +43,7 @@ func Run(addr string) error {
if err := cfg.ApplyOverrides(overrides); err != nil { if err := cfg.ApplyOverrides(overrides); err != nil {
return fmt.Errorf("apply settings overrides: %w", err) return fmt.Errorf("apply settings overrides: %w", err)
} }
applyBoxstoreRuntimeConfig(cfg)
bootstrap, err := metastore.BootstrapAdmin(cfg, store) bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil { if err != nil {
@@ -83,3 +83,8 @@ func Run(addr string) error {
return router.Run(addr) return router.Run(addr)
} }
func applyBoxstoreRuntimeConfig(cfg *config.Config) {
boxstore.SetUploadRoot(cfg.UploadsDir)
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
}

2
run.sh
View File

@@ -11,6 +11,8 @@ export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}"
export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}" export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}"
export WARPBOX_ZIP_DOWNLOADS_ENABLED="${WARPBOX_ZIP_DOWNLOADS_ENABLED:-true}" export WARPBOX_ZIP_DOWNLOADS_ENABLED="${WARPBOX_ZIP_DOWNLOADS_ENABLED:-true}"
export WARPBOX_ONE_TIME_DOWNLOADS_ENABLED="${WARPBOX_ONE_TIME_DOWNLOADS_ENABLED:-true}" export WARPBOX_ONE_TIME_DOWNLOADS_ENABLED="${WARPBOX_ONE_TIME_DOWNLOADS_ENABLED:-true}"
export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS:-604800}" # 7 days
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}"
# Storage and expiry limits used by the upload UI and backend validators. # Storage and expiry limits used by the upload UI and backend validators.
# Use megabytes here; WarpBox converts these to bytes internally. # Use megabytes here; WarpBox converts these to bytes internally.

17
test.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [ -n "${GO_BIN:-}" ]; then
go_bin="$GO_BIN"
elif command -v go >/dev/null 2>&1; then
go_bin="$(command -v go)"
elif [ -x /home/linuxbrew/.linuxbrew/bin/go ]; then
go_bin=/home/linuxbrew/.linuxbrew/bin/go
else
echo "go not found. Set GO_BIN=/path/to/go or install Go." >&2
exit 127
fi
"$go_bin" test ./... "$@"