diff --git a/.env.example b/.env.example index 8bf1a8b..487014f 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ WARPBOX_GUEST_UPLOADS_ENABLED=true WARPBOX_API_ENABLED=true WARPBOX_ZIP_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) WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 @@ -22,4 +24,4 @@ WARPBOX_DATA_DIR=./data # Admin Area WARPBOX_ADMIN_ENABLED=true -WARPBOX_ADMIN_PASSWORD=123 \ No newline at end of file +WARPBOX_ADMIN_PASSWORD=123 diff --git a/Dockerfile b/Dockerfile index 4eb8d9a..4c99a87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,8 @@ ENV WARPBOX_DATA_DIR=/app/data \ WARPBOX_API_ENABLED=true \ WARPBOX_ZIP_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_GLOBAL_MAX_FILE_SIZE_MB=2048 \ WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \ @@ -60,4 +62,4 @@ EXPOSE 8080 VOLUME ["/app/data"] -CMD ["./warpbox", "run", "--addr", ":8080"] \ No newline at end of file +CMD ["./warpbox", "run", "--addr", ":8080"] diff --git a/README.md b/README.md index 95f7eeb..df02718 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ settings remain environment controlled. | `WARPBOX_API_ENABLED` | `true` | Enables JSON/upload endpoints used by the UI. | | `WARPBOX_ZIP_DOWNLOADS_ENABLED` | `true` | Enables ZIP downloads. | | `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_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. | | `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. | @@ -121,6 +123,7 @@ Example: ```bash WARPBOX_ADMIN_PASSWORD='change-me' \ +WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \ WARPBOX_BOX_POLL_INTERVAL_MS=2000 \ WARPBOX_THUMBNAIL_BATCH_SIZE=20 \ WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \ diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go index b3a307c..ede13ef 100644 --- a/lib/boxstore/store.go +++ b/lib/boxstore/store.go @@ -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) diff --git a/lib/boxstore/store_test.go b/lib/boxstore/store_test.go index e761c46..8fe0192 100644 --- a/lib/boxstore/store_test.go +++ b/lib/boxstore/store_test.go @@ -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) diff --git a/lib/boxstore/thumbnails.go b/lib/boxstore/thumbnails.go index d7dd289..257beb7 100644 --- a/lib/boxstore/thumbnails.go +++ b/lib/boxstore/thumbnails.go @@ -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 } diff --git a/lib/config/config.go b/lib/config/config.go index e2afa00..45de2b7 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -31,6 +31,7 @@ const ( SettingZipDownloadsEnabled = "zip_downloads_enabled" SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled" SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds" + SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure" SettingRenewOnAccessEnabled = "renew_on_access_enabled" SettingRenewOnDownloadEnabled = "renew_on_download_enabled" SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds" @@ -83,13 +84,14 @@ type Config struct { AdminCookieSecure bool AllowAdminSettingsOverride bool - GuestUploadsEnabled bool - APIEnabled bool - ZipDownloadsEnabled bool - OneTimeDownloadsEnabled bool - OneTimeDownloadExpirySeconds int64 - RenewOnAccessEnabled bool - RenewOnDownloadEnabled bool + GuestUploadsEnabled bool + APIEnabled bool + ZipDownloadsEnabled bool + OneTimeDownloadsEnabled bool + OneTimeDownloadExpirySeconds int64 + OneTimeDownloadRetryOnFailure bool + RenewOnAccessEnabled bool + RenewOnDownloadEnabled bool DefaultGuestExpirySeconds 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: 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: 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: 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}, @@ -129,23 +132,24 @@ var Definitions = []SettingDefinition{ func Load() (*Config, error) { cfg := &Config{ - DataDir: "./data", - AdminUsername: "admin", - AdminEnabled: AdminEnabledAuto, - AllowAdminSettingsOverride: true, - GuestUploadsEnabled: true, - APIEnabled: true, - ZipDownloadsEnabled: true, - OneTimeDownloadsEnabled: true, - OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60, - DefaultGuestExpirySeconds: 10, - MaxGuestExpirySeconds: 48 * 60 * 60, - SessionTTLSeconds: 24 * 60 * 60, - BoxPollIntervalMS: 5000, - ThumbnailBatchSize: 10, - ThumbnailIntervalSeconds: 30, - sources: make(map[string]Source), - values: make(map[string]string), + DataDir: "./data", + AdminUsername: "admin", + AdminEnabled: AdminEnabledAuto, + AllowAdminSettingsOverride: true, + GuestUploadsEnabled: true, + APIEnabled: true, + ZipDownloadsEnabled: true, + OneTimeDownloadsEnabled: true, + OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60, + OneTimeDownloadRetryOnFailure: false, + DefaultGuestExpirySeconds: 10, + MaxGuestExpirySeconds: 48 * 60 * 60, + SessionTTLSeconds: 24 * 60 * 60, + BoxPollIntervalMS: 5000, + ThumbnailBatchSize: 10, + ThumbnailIntervalSeconds: 30, + sources: make(map[string]Source), + values: make(map[string]string), } cfg.captureDefaults() @@ -185,6 +189,7 @@ func Load() (*Config, error) { {SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled}, {SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled}, {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}, {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(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), 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(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault) cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault) diff --git a/lib/config/config_test.go b/lib/config/config_test.go index d167f6c..aebfaa6 100644 --- a/lib/config/config_test.go +++ b/lib/config/config_test.go @@ -38,6 +38,7 @@ func TestEnvironmentOverrides(t *testing.T) { t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100") t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000") t.Setenv("WARPBOX_ADMIN_USERNAME", "root") + t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true") cfg, err := Load() if err != nil { @@ -59,6 +60,9 @@ func TestEnvironmentOverrides(t *testing.T) { if cfg.AdminUsername != "root" { 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 { 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_ZIP_DOWNLOADS_ENABLED", "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", + "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "WARPBOX_RENEW_ON_ACCESS_ENABLED", "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", diff --git a/lib/server/admin.go b/lib/server/admin.go index eb70857..7dc6233 100644 --- a/lib/server/admin.go +++ b/lib/server/admin.go @@ -373,6 +373,7 @@ func (app *App) handleAdminSettingsPost(ctx *gin.Context) { return } } + applyBoxstoreRuntimeConfig(app.config) ctx.Redirect(http.StatusSeeOther, "/admin/settings") } diff --git a/lib/server/handlers.go b/lib/server/handlers.go index 7a8bdf6..f49804a 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -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") diff --git a/lib/server/one_time_test.go b/lib/server/one_time_test.go new file mode 100644 index 0000000..c46b8ec --- /dev/null +++ b/lib/server/one_time_test.go @@ -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 +} diff --git a/lib/server/server.go b/lib/server/server.go index a285ea2..26c3876 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -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) +} diff --git a/run.sh b/run.sh index e2bec9c..9ebe1f9 100755 --- a/run.sh +++ b/run.sh @@ -11,6 +11,8 @@ export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}" export WARPBOX_API_ENABLED="${WARPBOX_API_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_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. # Use megabytes here; WarpBox converts these to bytes internally. @@ -37,4 +39,4 @@ if [ "${1:-}" = "--docker" ]; then exit 0 fi -go run ./cmd/main.go run \ No newline at end of file +go run ./cmd/main.go run diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..1d179e9 --- /dev/null +++ b/test.sh @@ -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 ./... "$@"