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
}

View File

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

View File

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

View File

@@ -373,6 +373,7 @@ func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
return
}
}
applyBoxstoreRuntimeConfig(app.config)
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")
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
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
}
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)
}