diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go index cc7046e..c03e3c6 100644 --- a/lib/boxstore/store.go +++ b/lib/boxstore/store.go @@ -24,6 +24,8 @@ import ( const ( UploadRoot = "data/uploads" manifestFile = ".warpbox.json" + + OneTimeDownloadRetentionKey = "one-time" ) var manifestMu sync.Mutex @@ -35,6 +37,7 @@ var retentionOptions = []models.RetentionOption{ {Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60}, {Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60}, {Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60}, + {Key: OneTimeDownloadRetentionKey, Label: "One time download", Seconds: 0}, } func NewBoxID() (string, error) { @@ -120,14 +123,19 @@ func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.Box if request.AllowZip != nil { disableZip = !*request.AllowZip } + oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey + if oneTimeDownload { + disableZip = false + } manifest := models.BoxManifest{ - Files: files, - CreatedAt: now, - RetentionKey: retention.Key, - RetentionLabel: retention.Label, - RetentionSecs: retention.Seconds, - DisableZip: disableZip, + Files: files, + CreatedAt: now, + RetentionKey: retention.Key, + RetentionLabel: retention.Label, + RetentionSecs: retention.Seconds, + DisableZip: disableZip, + OneTimeDownload: oneTimeDownload, } if password := strings.TrimSpace(request.Password); password != "" { @@ -479,6 +487,9 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) { if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 { return } + if manifest.OneTimeDownload { + return + } for _, file := range manifest.Files { if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed { diff --git a/lib/boxstore/store_test.go b/lib/boxstore/store_test.go index 7abbb1f..ba003f4 100644 --- a/lib/boxstore/store_test.go +++ b/lib/boxstore/store_test.go @@ -42,3 +42,20 @@ func TestStartRetentionBeginsWhenEveryFileIsTerminal(t *testing.T) { t.Fatalf("expected retention to start from completion time, got %s", manifest.ExpiresAt) } } + +func TestStartRetentionSkipsOneTimeDownload(t *testing.T) { + manifest := models.BoxManifest{ + RetentionSecs: 10, + OneTimeDownload: true, + Files: []models.BoxFile{ + {ID: "one", Status: models.FileStatusReady}, + {ID: "two", Status: models.FileStatusReady}, + }, + } + + startRetentionIfTerminalUnlocked(&manifest) + + if !manifest.ExpiresAt.IsZero() { + t.Fatalf("expected one-time download box to avoid retention expiry, got %s", manifest.ExpiresAt) + } +} diff --git a/lib/models/models.go b/lib/models/models.go index 5155098..c696d84 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -41,16 +41,17 @@ type BoxFile struct { } type BoxManifest struct { - Files []BoxFile `json:"files"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - RetentionKey string `json:"retention_key"` - RetentionLabel string `json:"retention_label"` - RetentionSecs int64 `json:"retention_seconds"` - PasswordSalt string `json:"password_salt,omitempty"` - PasswordHash string `json:"password_hash,omitempty"` - AuthToken string `json:"auth_token,omitempty"` - DisableZip bool `json:"disable_zip,omitempty"` + Files []BoxFile `json:"files"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RetentionKey string `json:"retention_key"` + RetentionLabel string `json:"retention_label"` + RetentionSecs int64 `json:"retention_seconds"` + PasswordSalt string `json:"password_salt,omitempty"` + PasswordHash string `json:"password_hash,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + DisableZip bool `json:"disable_zip,omitempty"` + OneTimeDownload bool `json:"one_time_download,omitempty"` } type CreateBoxRequest struct { diff --git a/lib/server/handlers.go b/lib/server/handlers.go index 23de7f4..2a9554c 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -52,6 +52,7 @@ func handleShowBox(ctx *gin.Context) { "Files": files, "FileCount": len(files), "DownloadAll": downloadAll, + "ZipOnly": hasManifest && manifest.OneTimeDownload, "PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000), "RetentionLabel": manifest.RetentionLabel, "ExpiresAt": manifest.ExpiresAt, @@ -163,12 +164,21 @@ func handleDownloadBox(ctx *gin.Context) { ctx.String(http.StatusNotFound, "Box not found") return } + if hasManifest && manifest.OneTimeDownload && !allFilesComplete(files) { + ctx.String(http.StatusConflict, "Box is not ready yet") + return + } ctx.Header("Content-Type", "application/zip") ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID)) zipWriter := zip.NewWriter(ctx.Writer) - defer zipWriter.Close() + zipClosed := false + defer func() { + if !zipClosed { + zipWriter.Close() + } + }() for _, file := range files { if !file.IsComplete { @@ -180,6 +190,31 @@ func handleDownloadBox(ctx *gin.Context) { return } } + + if err := zipWriter.Close(); err != nil { + zipClosed = true + ctx.Status(http.StatusInternalServerError) + return + } + zipClosed = true + + if hasManifest && manifest.OneTimeDownload { + boxstore.DeleteBox(boxID) + } +} + +func allFilesComplete(files []models.BoxFile) bool { + if len(files) == 0 { + return false + } + + for _, file := range files { + if !file.IsComplete { + return false + } + } + + return true } func handleDownloadFile(ctx *gin.Context) { @@ -190,7 +225,12 @@ func handleDownloadFile(ctx *gin.Context) { return } - if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized { + manifest, hasManifest, authorized := authorizeBoxRequest(ctx, boxID, true) + if !authorized { + return + } + if hasManifest && manifest.OneTimeDownload { + ctx.String(http.StatusForbidden, "Individual downloads disabled for this box") return } diff --git a/static/js/app.js b/static/js/app.js index 24617a7..801799c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -14,6 +14,7 @@ const retentionSelect = document.querySelector("#upload-retention"); const passwordEnabled = document.querySelector("#upload-password-enabled"); const passwordInput = document.querySelector("#upload-password"); const zipEnabled = document.querySelector("#upload-zip-enabled"); +const oneTimeRetentionKey = "one-time"; let selectedFiles = []; let statusTimer = null; @@ -118,6 +119,24 @@ function setBoxStatus(message) { } } +function isOneTimeDownloadSelected() { + return retentionSelect && retentionSelect.value === oneTimeRetentionKey; +} + +function updateZipOptionForRetention() { + if (!zipEnabled) { + return; + } + + if (isOneTimeDownloadSelected()) { + zipEnabled.checked = true; + zipEnabled.disabled = true; + return; + } + + zipEnabled.disabled = false; +} + function setBoxLink(path) { shareURL = path ? new URL(path, window.location.origin).toString() : ""; @@ -257,7 +276,7 @@ async function createBox() { body: JSON.stringify({ retention_key: retentionSelect ? retentionSelect.value : "10s", password: passwordEnabled && passwordEnabled.checked && passwordInput ? passwordInput.value : "", - allow_zip: !zipEnabled || zipEnabled.checked, + allow_zip: isOneTimeDownloadSelected() || !zipEnabled || zipEnabled.checked, files: selectedFiles.map((selectedFile) => ({ name: selectedFile.file.name, size: selectedFile.file.size, @@ -383,6 +402,11 @@ if (passwordEnabled && passwordInput) { }); } +if (retentionSelect) { + updateZipOptionForRetention(); + retentionSelect.addEventListener("change", updateZipOptionForRetention); +} + if (fileInput && dropzone) { dropzone.addEventListener("dragover", (event) => { event.preventDefault(); diff --git a/static/js/box.js b/static/js/box.js index f5e7dc1..4148c4e 100644 --- a/static/js/box.js +++ b/static/js/box.js @@ -1,5 +1,6 @@ const boxPanel = document.querySelector(".box-panel[data-box-id]"); const boxStatus = document.querySelector(".box-statusbar span:first-child"); +const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true"; document.querySelectorAll('.box-file[aria-disabled="true"]').forEach((item) => { item.addEventListener("click", (event) => { @@ -27,7 +28,7 @@ function updateBoxFile(file) { item.dataset.status = file.status; item.title = file.title; - if (isComplete) { + if (isComplete && !zipOnly) { item.href = file.download_path; item.setAttribute("download", ""); item.removeAttribute("aria-disabled"); diff --git a/templates/box.html b/templates/box.html index 0019b55..4435a34 100644 --- a/templates/box.html +++ b/templates/box.html @@ -53,11 +53,11 @@ {{ end }} -
+
{{ if .Files }}