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:
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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 {
|
} else if seconds <= 0 {
|
||||||
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -88,6 +89,7 @@ type Config struct {
|
|||||||
ZipDownloadsEnabled bool
|
ZipDownloadsEnabled bool
|
||||||
OneTimeDownloadsEnabled bool
|
OneTimeDownloadsEnabled bool
|
||||||
OneTimeDownloadExpirySeconds int64
|
OneTimeDownloadExpirySeconds int64
|
||||||
|
OneTimeDownloadRetryOnFailure bool
|
||||||
RenewOnAccessEnabled bool
|
RenewOnAccessEnabled bool
|
||||||
RenewOnDownloadEnabled bool
|
RenewOnDownloadEnabled bool
|
||||||
|
|
||||||
@@ -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},
|
||||||
@@ -138,6 +141,7 @@ func Load() (*Config, error) {
|
|||||||
ZipDownloadsEnabled: true,
|
ZipDownloadsEnabled: true,
|
||||||
OneTimeDownloadsEnabled: true,
|
OneTimeDownloadsEnabled: true,
|
||||||
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
||||||
|
OneTimeDownloadRetryOnFailure: false,
|
||||||
DefaultGuestExpirySeconds: 10,
|
DefaultGuestExpirySeconds: 10,
|
||||||
MaxGuestExpirySeconds: 48 * 60 * 60,
|
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||||
SessionTTLSeconds: 24 * 60 * 60,
|
SessionTTLSeconds: 24 * 60 * 60,
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,16 +151,25 @@ 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 hasManifest && manifestFilesReady(manifest.Files) {
|
||||||
|
files = boxstore.DecorateFiles(boxID, manifest.Files)
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
files, err = boxstore.ListFiles(boxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
||||||
return
|
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)
|
|
||||||
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 {
|
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
219
lib/server/one_time_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
2
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_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
17
test.sh
Executable 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 ./... "$@"
|
||||||
Reference in New Issue
Block a user