feat(boxstore): add one-time download retention mode
Introduce a `one-time` retention option and persist it on the manifest as `one_time_download`. One-time download boxes bypass retention expiry scheduling, force zip downloads, and reject download attempts until all files are complete to prevent partial retrievals.feat(boxstore): add one-time download retention mode Introduce a `one-time` retention option and persist it on the manifest as `one_time_download`. One-time download boxes bypass retention expiry scheduling, force zip downloads, and reject download attempts until all files are complete to prevent partial retrievals.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user