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

@@ -31,8 +31,9 @@ const (
) )
var ( var (
uploadRoot = filepath.Join("data", "uploads") uploadRoot = filepath.Join("data", "uploads")
manifestMu sync.Mutex oneTimeDownloadExpiry int64
manifestMu sync.Mutex
) )
var retentionOptions = []models.RetentionOption{ var retentionOptions = []models.RetentionOption{
@@ -70,6 +71,10 @@ func SetUploadRoot(path string) {
uploadRoot = filepath.Clean(path) uploadRoot = filepath.Clean(path)
} }
func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds
}
func UploadRoot() string { func UploadRoot() string {
return uploadRoot return uploadRoot
} }
@@ -638,7 +643,17 @@ 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 {
seconds := manifest.RetentionSecs
if seconds <= 0 {
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
}
if manifest.OneTimeDownload && seconds <= 0 {
return return
} }
@@ -648,10 +663,7 @@ func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
} }
} }
seconds := manifest.RetentionSecs // seconds is already handled above
if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
// 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.

View File

@@ -26,23 +26,24 @@ const (
) )
const ( const (
SettingGuestUploadsEnabled = "guest_uploads_enabled" SettingGuestUploadsEnabled = "guest_uploads_enabled"
SettingAPIEnabled = "api_enabled" SettingAPIEnabled = "api_enabled"
SettingZipDownloadsEnabled = "zip_downloads_enabled" SettingZipDownloadsEnabled = "zip_downloads_enabled"
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled" SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
SettingRenewOnAccessEnabled = "renew_on_access_enabled" SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
SettingRenewOnDownloadEnabled = "renew_on_download_enabled" SettingRenewOnAccessEnabled = "renew_on_access_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds" SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds" SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes" SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes" SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes" SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes" SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
SettingSessionTTLSeconds = "session_ttl_seconds" SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
SettingBoxPollIntervalMS = "box_poll_interval_ms" SettingSessionTTLSeconds = "session_ttl_seconds"
SettingThumbnailBatchSize = "thumbnail_batch_size" SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingDataDir = "data_dir" SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
) )
type SettingType string type SettingType string
@@ -82,12 +83,13 @@ type Config struct {
AdminCookieSecure bool AdminCookieSecure bool
AllowAdminSettingsOverride bool AllowAdminSettingsOverride bool
GuestUploadsEnabled bool GuestUploadsEnabled bool
APIEnabled bool APIEnabled bool
ZipDownloadsEnabled bool ZipDownloadsEnabled bool
OneTimeDownloadsEnabled bool OneTimeDownloadsEnabled bool
RenewOnAccessEnabled bool OneTimeDownloadExpirySeconds int64
RenewOnDownloadEnabled bool RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64 DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64 MaxGuestExpirySeconds int64
@@ -110,6 +112,7 @@ var Definitions = []SettingDefinition{
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true}, {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: 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: 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},
@@ -126,22 +129,23 @@ var Definitions = []SettingDefinition{
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
DataDir: "./data", DataDir: "./data",
AdminUsername: "admin", AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto, AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true, AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true, GuestUploadsEnabled: true,
APIEnabled: true, APIEnabled: true,
ZipDownloadsEnabled: true, ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true, OneTimeDownloadsEnabled: true,
DefaultGuestExpirySeconds: 10, OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
MaxGuestExpirySeconds: 48 * 60 * 60, DefaultGuestExpirySeconds: 10,
SessionTTLSeconds: 24 * 60 * 60, MaxGuestExpirySeconds: 48 * 60 * 60,
BoxPollIntervalMS: 5000, SessionTTLSeconds: 24 * 60 * 60,
ThumbnailBatchSize: 10, BoxPollIntervalMS: 5000,
ThumbnailIntervalSeconds: 30, ThumbnailBatchSize: 10,
sources: make(map[string]Source), ThumbnailIntervalSeconds: 30,
values: make(map[string]string), sources: make(map[string]Source),
values: make(map[string]string),
} }
cfg.captureDefaults() cfg.captureDefaults()
@@ -198,6 +202,7 @@ func Load() (*Config, error) {
}{ }{
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds}, {SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, {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}, {SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
} }
for _, item := range envInt64s { for _, item := range envInt64s {
@@ -359,6 +364,7 @@ func (cfg *Config) captureDefaults() {
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault) cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
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(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)
@@ -485,6 +491,8 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultGuestExpirySeconds = value cfg.DefaultGuestExpirySeconds = value
case SettingMaxGuestExpirySecs: case SettingMaxGuestExpirySecs:
cfg.MaxGuestExpirySeconds = value cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value
case SettingDefaultUserMaxFileBytes: case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes: case SettingDefaultUserMaxBoxBytes:

View File

@@ -53,6 +53,7 @@ type BoxManifest struct {
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"` OneTimeDownload bool `json:"one_time_download,omitempty"`
Consumed bool `json:"consumed,omitempty"`
} }
type BoxSummary struct { type BoxSummary struct {

View File

@@ -211,8 +211,14 @@ func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
if !ok { if !ok {
return return
} }
if !hasManifest || !manifest.OneTimeDownload { if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
ctx.String(http.StatusNotFound, "Box not found") 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 return
} }
@@ -327,7 +333,12 @@ func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
return 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 return
} }

View File

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

View File

@@ -387,12 +387,51 @@ textarea:disabled {
:root { --base-font-size: 18px; --ui-scale: 1.88; } :root { --base-font-size: 18px; --ui-scale: 1.88; }
} }
@media (prefers-reduced-motion: reduce) { .start-upload-cta {
*, min-width: 128px;
*::before, position: relative;
*::after { overflow: visible;
animation-duration: 1ms !important; isolation: isolate;
animation-iteration-count: 1 !important; font-weight: bold;
scroll-behavior: auto !important; }
}
.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,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
} }

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> <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> <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 }} {{ 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 }} {{ end }}
</div> </div>