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 (
|
const (
|
||||||
UploadRoot = "data/uploads"
|
UploadRoot = "data/uploads"
|
||||||
manifestFile = ".warpbox.json"
|
manifestFile = ".warpbox.json"
|
||||||
|
|
||||||
|
OneTimeDownloadRetentionKey = "one-time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var manifestMu sync.Mutex
|
var manifestMu sync.Mutex
|
||||||
@@ -35,6 +37,7 @@ var retentionOptions = []models.RetentionOption{
|
|||||||
{Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60},
|
{Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60},
|
||||||
{Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60},
|
{Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60},
|
||||||
{Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60},
|
{Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60},
|
||||||
|
{Key: OneTimeDownloadRetentionKey, Label: "One time download", Seconds: 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBoxID() (string, error) {
|
func NewBoxID() (string, error) {
|
||||||
@@ -120,14 +123,19 @@ func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.Box
|
|||||||
if request.AllowZip != nil {
|
if request.AllowZip != nil {
|
||||||
disableZip = !*request.AllowZip
|
disableZip = !*request.AllowZip
|
||||||
}
|
}
|
||||||
|
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
|
||||||
|
if oneTimeDownload {
|
||||||
|
disableZip = false
|
||||||
|
}
|
||||||
|
|
||||||
manifest := models.BoxManifest{
|
manifest := models.BoxManifest{
|
||||||
Files: files,
|
Files: files,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
RetentionKey: retention.Key,
|
RetentionKey: retention.Key,
|
||||||
RetentionLabel: retention.Label,
|
RetentionLabel: retention.Label,
|
||||||
RetentionSecs: retention.Seconds,
|
RetentionSecs: retention.Seconds,
|
||||||
DisableZip: disableZip,
|
DisableZip: disableZip,
|
||||||
|
OneTimeDownload: oneTimeDownload,
|
||||||
}
|
}
|
||||||
|
|
||||||
if password := strings.TrimSpace(request.Password); password != "" {
|
if password := strings.TrimSpace(request.Password); password != "" {
|
||||||
@@ -479,6 +487,9 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
|
|||||||
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
|
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if manifest.OneTimeDownload {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, file := range manifest.Files {
|
for _, file := range manifest.Files {
|
||||||
if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed {
|
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)
|
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 {
|
type BoxManifest struct {
|
||||||
Files []BoxFile `json:"files"`
|
Files []BoxFile `json:"files"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
RetentionKey string `json:"retention_key"`
|
RetentionKey string `json:"retention_key"`
|
||||||
RetentionLabel string `json:"retention_label"`
|
RetentionLabel string `json:"retention_label"`
|
||||||
RetentionSecs int64 `json:"retention_seconds"`
|
RetentionSecs int64 `json:"retention_seconds"`
|
||||||
PasswordSalt string `json:"password_salt,omitempty"`
|
PasswordSalt string `json:"password_salt,omitempty"`
|
||||||
PasswordHash string `json:"password_hash,omitempty"`
|
PasswordHash string `json:"password_hash,omitempty"`
|
||||||
AuthToken string `json:"auth_token,omitempty"`
|
AuthToken string `json:"auth_token,omitempty"`
|
||||||
DisableZip bool `json:"disable_zip,omitempty"`
|
DisableZip bool `json:"disable_zip,omitempty"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBoxRequest struct {
|
type CreateBoxRequest struct {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func handleShowBox(ctx *gin.Context) {
|
|||||||
"Files": files,
|
"Files": files,
|
||||||
"FileCount": len(files),
|
"FileCount": len(files),
|
||||||
"DownloadAll": downloadAll,
|
"DownloadAll": downloadAll,
|
||||||
|
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
||||||
"PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000),
|
"PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000),
|
||||||
"RetentionLabel": manifest.RetentionLabel,
|
"RetentionLabel": manifest.RetentionLabel,
|
||||||
"ExpiresAt": manifest.ExpiresAt,
|
"ExpiresAt": manifest.ExpiresAt,
|
||||||
@@ -163,12 +164,21 @@ func handleDownloadBox(ctx *gin.Context) {
|
|||||||
ctx.String(http.StatusNotFound, "Box not found")
|
ctx.String(http.StatusNotFound, "Box not found")
|
||||||
return
|
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-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)
|
zipWriter := zip.NewWriter(ctx.Writer)
|
||||||
defer zipWriter.Close()
|
zipClosed := false
|
||||||
|
defer func() {
|
||||||
|
if !zipClosed {
|
||||||
|
zipWriter.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsComplete {
|
if !file.IsComplete {
|
||||||
@@ -180,6 +190,31 @@ func handleDownloadBox(ctx *gin.Context) {
|
|||||||
return
|
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) {
|
func handleDownloadFile(ctx *gin.Context) {
|
||||||
@@ -190,7 +225,12 @@ func handleDownloadFile(ctx *gin.Context) {
|
|||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const retentionSelect = document.querySelector("#upload-retention");
|
|||||||
const passwordEnabled = document.querySelector("#upload-password-enabled");
|
const passwordEnabled = document.querySelector("#upload-password-enabled");
|
||||||
const passwordInput = document.querySelector("#upload-password");
|
const passwordInput = document.querySelector("#upload-password");
|
||||||
const zipEnabled = document.querySelector("#upload-zip-enabled");
|
const zipEnabled = document.querySelector("#upload-zip-enabled");
|
||||||
|
const oneTimeRetentionKey = "one-time";
|
||||||
|
|
||||||
let selectedFiles = [];
|
let selectedFiles = [];
|
||||||
let statusTimer = null;
|
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) {
|
function setBoxLink(path) {
|
||||||
shareURL = path ? new URL(path, window.location.origin).toString() : "";
|
shareURL = path ? new URL(path, window.location.origin).toString() : "";
|
||||||
|
|
||||||
@@ -257,7 +276,7 @@ async function createBox() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
retention_key: retentionSelect ? retentionSelect.value : "10s",
|
retention_key: retentionSelect ? retentionSelect.value : "10s",
|
||||||
password: passwordEnabled && passwordEnabled.checked && passwordInput ? passwordInput.value : "",
|
password: passwordEnabled && passwordEnabled.checked && passwordInput ? passwordInput.value : "",
|
||||||
allow_zip: !zipEnabled || zipEnabled.checked,
|
allow_zip: isOneTimeDownloadSelected() || !zipEnabled || zipEnabled.checked,
|
||||||
files: selectedFiles.map((selectedFile) => ({
|
files: selectedFiles.map((selectedFile) => ({
|
||||||
name: selectedFile.file.name,
|
name: selectedFile.file.name,
|
||||||
size: selectedFile.file.size,
|
size: selectedFile.file.size,
|
||||||
@@ -383,6 +402,11 @@ if (passwordEnabled && passwordInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (retentionSelect) {
|
||||||
|
updateZipOptionForRetention();
|
||||||
|
retentionSelect.addEventListener("change", updateZipOptionForRetention);
|
||||||
|
}
|
||||||
|
|
||||||
if (fileInput && dropzone) {
|
if (fileInput && dropzone) {
|
||||||
dropzone.addEventListener("dragover", (event) => {
|
dropzone.addEventListener("dragover", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const boxPanel = document.querySelector(".box-panel[data-box-id]");
|
const boxPanel = document.querySelector(".box-panel[data-box-id]");
|
||||||
const boxStatus = document.querySelector(".box-statusbar span:first-child");
|
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) => {
|
document.querySelectorAll('.box-file[aria-disabled="true"]').forEach((item) => {
|
||||||
item.addEventListener("click", (event) => {
|
item.addEventListener("click", (event) => {
|
||||||
@@ -27,7 +28,7 @@ function updateBoxFile(file) {
|
|||||||
item.dataset.status = file.status;
|
item.dataset.status = file.status;
|
||||||
item.title = file.title;
|
item.title = file.title;
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete && !zipOnly) {
|
||||||
item.href = file.download_path;
|
item.href = file.download_path;
|
||||||
item.setAttribute("download", "");
|
item.setAttribute("download", "");
|
||||||
item.removeAttribute("aria-disabled");
|
item.removeAttribute("aria-disabled");
|
||||||
|
|||||||
@@ -53,11 +53,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<div class="win98-panel box-panel" data-box-id="{{ .BoxID }}" data-poll-ms="{{ .PollMS }}">
|
<div class="win98-panel box-panel" data-box-id="{{ .BoxID }}" data-poll-ms="{{ .PollMS }}" data-zip-only="{{ if .ZipOnly }}true{{ else }}false{{ end }}">
|
||||||
{{ if .Files }}
|
{{ if .Files }}
|
||||||
<div class="box-file-grid" aria-label="Uploaded files">
|
<div class="box-file-grid" aria-label="Uploaded files">
|
||||||
{{ range .Files }}
|
{{ range .Files }}
|
||||||
<a class="box-file {{ if .IsComplete }}is-complete{{ else if eq .Status "failed" }}is-failed{{ else }}is-loading{{ end }} {{ if .ThumbnailURL }}has-thumbnail{{ end }}" href="{{ if .IsComplete }}{{ .DownloadPath }}{{ else }}#{{ end }}" title="{{ .Title }}" data-file-id="{{ .ID }}" data-status="{{ .Status }}" {{ if .IsComplete }}download{{ else }}aria-disabled="true"{{ end }}>
|
<a class="box-file {{ if .IsComplete }}is-complete{{ else if eq .Status "failed" }}is-failed{{ else }}is-loading{{ end }} {{ if .ThumbnailURL }}has-thumbnail{{ end }}" href="{{ if and .IsComplete (not $.ZipOnly) }}{{ .DownloadPath }}{{ else }}#{{ end }}" title="{{ if $.ZipOnly }}Available in ZIP download{{ else }}{{ .Title }}{{ end }}" data-file-id="{{ .ID }}" data-status="{{ .Status }}" {{ if and .IsComplete (not $.ZipOnly) }}download{{ else }}aria-disabled="true"{{ end }}>
|
||||||
<img class="box-file-icon" src="{{ if .ThumbnailURL }}{{ .ThumbnailURL }}{{ else }}{{ .IconPath }}{{ end }}" alt="" aria-hidden="true">
|
<img class="box-file-icon" src="{{ if .ThumbnailURL }}{{ .ThumbnailURL }}{{ else }}{{ .IconPath }}{{ end }}" alt="" aria-hidden="true">
|
||||||
<span class="box-file-name">{{ .Name }}</span>
|
<span class="box-file-name">{{ .Name }}</span>
|
||||||
<span class="box-file-meta">{{ .StatusLabel }} · {{ .SizeLabel }}</span>
|
<span class="box-file-meta">{{ .StatusLabel }} · {{ .SizeLabel }}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user