feat(boxstore): add configurable expiry for one-time downloads

Introduces a new configuration setting `one_time_download_expiry_seconds` to allow administrators to define a default expiration period for one-time downloads.

The retention logic in `boxstore` has been updated to use this global expiry value when a box is marked as a one-time download and no specific retention period is defined in the manifest.
This commit is contained in:
2026-04-30 03:54:50 +03:00
parent 6b9f6ac291
commit 7d70a0c2ed
7 changed files with 130 additions and 58 deletions

View File

@@ -32,6 +32,7 @@ const (
var (
uploadRoot = filepath.Join("data", "uploads")
oneTimeDownloadExpiry int64
manifestMu sync.Mutex
)
@@ -70,6 +71,10 @@ func SetUploadRoot(path string) {
uploadRoot = filepath.Clean(path)
}
func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds
}
func UploadRoot() string {
return uploadRoot
}
@@ -638,7 +643,17 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
return
}
seconds := manifest.RetentionSecs
if seconds <= 0 {
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
}
if manifest.OneTimeDownload && seconds <= 0 {
return
}
@@ -648,10 +663,7 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
}
}
seconds := manifest.RetentionSecs
if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
// 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.

View File

@@ -30,6 +30,7 @@ const (
SettingAPIEnabled = "api_enabled"
SettingZipDownloadsEnabled = "zip_downloads_enabled"
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
@@ -86,6 +87,7 @@ type Config struct {
APIEnabled bool
ZipDownloadsEnabled bool
OneTimeDownloadsEnabled bool
OneTimeDownloadExpirySeconds int64
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
@@ -110,6 +112,7 @@ var Definitions = []SettingDefinition{
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API 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: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{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},
@@ -134,6 +137,7 @@ func Load() (*Config, error) {
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
@@ -198,6 +202,7 @@ func Load() (*Config, error) {
}{
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
}
for _, item := range envInt64s {
@@ -359,6 +364,7 @@ func (cfg *Config) captureDefaults() {
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
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(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
@@ -485,6 +491,8 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultGuestExpirySeconds = value
case SettingMaxGuestExpirySecs:
cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value
case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes:

View File

@@ -53,6 +53,7 @@ type BoxManifest struct {
AuthToken string `json:"auth_token,omitempty"`
DisableZip bool `json:"disable_zip,omitempty"`
OneTimeDownload bool `json:"one_time_download,omitempty"`
Consumed bool `json:"consumed,omitempty"`
}
type BoxSummary struct {

View File

@@ -211,8 +211,14 @@ func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
if !ok {
return
}
if !hasManifest || !manifest.OneTimeDownload {
ctx.String(http.StatusNotFound, "Box not found")
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
ctx.String(http.StatusGone, "Box already consumed")
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
return
}
@@ -327,7 +333,12 @@ func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
return
}
if _, _, authorized := app.authorizeBoxRequest(ctx, boxID, true); !authorized {
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
return
}

View File

@@ -29,6 +29,7 @@ func Run(addr string) error {
}
boxstore.SetUploadRoot(cfg.UploadsDir)
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
store, err := metastore.Open(cfg.DBDir)
if err != nil {

View File

@@ -387,6 +387,45 @@ textarea:disabled {
:root { --base-font-size: 18px; --ui-scale: 1.88; }
}
.start-upload-cta {
min-width: 128px;
position: relative;
overflow: visible;
isolation: isolate;
font-weight: bold;
}
.start-upload-cta.is-current-step {
animation: start-ready-rainbow-breathe 1150ms ease-in-out infinite;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 0 0 1px #000000;
}
.start-upload-cta.is-current-step::after {
content: "";
position: absolute;
inset: -4px;
pointer-events: none;
z-index: 1;
padding: 4px;
background: linear-gradient(90deg, #ff004c, #ffcc00, #00d26a, #00a2ff, #8c48ff, #ff004c, #ffcc00);
background-size: 280% 100%;
opacity: .9;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: start-border-rainbow-slide 1850ms linear infinite;
}
@keyframes start-ready-rainbow-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
@keyframes start-border-rainbow-slide {
from { background-position: 0% 0%; }
to { background-position: 200% 0%; }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View File

@@ -29,7 +29,7 @@
<button class="box-address" type="button" id="box-address" data-copy-url="{{ .BoxID }}" title="Copy current page URL">{{ .BoxID }}</button>
<a class="win98-button box-toolbar-button" href="/"><img src="/static/img/icons/directory_open_cool-4.png" alt="" aria-hidden="true"><span>Upload</span></a>
{{ if .DownloadAll }}
<a class="win98-button box-toolbar-button" href="{{ .DownloadAll }}"><img src="/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png" alt="" aria-hidden="true"><span>Download Zip</span></a>
<a class="win98-button box-toolbar-button {{ if or .ZipOnly (gt (len .Files) 1) }}start-upload-cta is-current-step{{ end }}" href="{{ .DownloadAll }}"><img src="/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png" alt="" aria-hidden="true"><span>Download Zip</span></a>
{{ end }}
</div>