diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go
index 26c39eb..b3a307c 100644
--- a/lib/boxstore/store.go
+++ b/lib/boxstore/store.go
@@ -31,8 +31,9 @@ const (
)
var (
- uploadRoot = filepath.Join("data", "uploads")
- manifestMu sync.Mutex
+ uploadRoot = filepath.Join("data", "uploads")
+ oneTimeDownloadExpiry int64
+ manifestMu sync.Mutex
)
var retentionOptions = []models.RetentionOption{
@@ -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
}
- 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
}
@@ -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.
diff --git a/lib/config/config.go b/lib/config/config.go
index 0ae71f4..e2afa00 100644
--- a/lib/config/config.go
+++ b/lib/config/config.go
@@ -26,23 +26,24 @@ const (
)
const (
- SettingGuestUploadsEnabled = "guest_uploads_enabled"
- SettingAPIEnabled = "api_enabled"
- SettingZipDownloadsEnabled = "zip_downloads_enabled"
- SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
- SettingRenewOnAccessEnabled = "renew_on_access_enabled"
- SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
- SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
- SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
- SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
- SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
- SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
- SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
- SettingSessionTTLSeconds = "session_ttl_seconds"
- SettingBoxPollIntervalMS = "box_poll_interval_ms"
- SettingThumbnailBatchSize = "thumbnail_batch_size"
- SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
- SettingDataDir = "data_dir"
+ SettingGuestUploadsEnabled = "guest_uploads_enabled"
+ 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"
+ SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
+ SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
+ SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
+ SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
+ SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
+ SettingSessionTTLSeconds = "session_ttl_seconds"
+ SettingBoxPollIntervalMS = "box_poll_interval_ms"
+ SettingThumbnailBatchSize = "thumbnail_batch_size"
+ SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
+ SettingDataDir = "data_dir"
)
type SettingType string
@@ -82,12 +83,13 @@ type Config struct {
AdminCookieSecure bool
AllowAdminSettingsOverride bool
- GuestUploadsEnabled bool
- APIEnabled bool
- ZipDownloadsEnabled bool
- OneTimeDownloadsEnabled bool
- RenewOnAccessEnabled bool
- RenewOnDownloadEnabled bool
+ GuestUploadsEnabled bool
+ APIEnabled bool
+ ZipDownloadsEnabled bool
+ OneTimeDownloadsEnabled bool
+ OneTimeDownloadExpirySeconds int64
+ RenewOnAccessEnabled bool
+ RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
@@ -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},
@@ -126,22 +129,23 @@ var Definitions = []SettingDefinition{
func Load() (*Config, error) {
cfg := &Config{
- DataDir: "./data",
- AdminUsername: "admin",
- AdminEnabled: AdminEnabledAuto,
- AllowAdminSettingsOverride: true,
- GuestUploadsEnabled: true,
- APIEnabled: true,
- ZipDownloadsEnabled: true,
- OneTimeDownloadsEnabled: true,
- DefaultGuestExpirySeconds: 10,
- MaxGuestExpirySeconds: 48 * 60 * 60,
- SessionTTLSeconds: 24 * 60 * 60,
- BoxPollIntervalMS: 5000,
- ThumbnailBatchSize: 10,
- ThumbnailIntervalSeconds: 30,
- sources: make(map[string]Source),
- values: make(map[string]string),
+ DataDir: "./data",
+ AdminUsername: "admin",
+ AdminEnabled: AdminEnabledAuto,
+ AllowAdminSettingsOverride: true,
+ GuestUploadsEnabled: true,
+ APIEnabled: true,
+ ZipDownloadsEnabled: true,
+ OneTimeDownloadsEnabled: true,
+ OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
+ DefaultGuestExpirySeconds: 10,
+ MaxGuestExpirySeconds: 48 * 60 * 60,
+ SessionTTLSeconds: 24 * 60 * 60,
+ BoxPollIntervalMS: 5000,
+ ThumbnailBatchSize: 10,
+ ThumbnailIntervalSeconds: 30,
+ sources: make(map[string]Source),
+ values: make(map[string]string),
}
cfg.captureDefaults()
@@ -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:
diff --git a/lib/models/models.go b/lib/models/models.go
index 1df17d4..4e2424f 100644
--- a/lib/models/models.go
+++ b/lib/models/models.go
@@ -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 {
diff --git a/lib/server/handlers.go b/lib/server/handlers.go
index 651ab69..7a8bdf6 100644
--- a/lib/server/handlers.go
+++ b/lib/server/handlers.go
@@ -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
}
diff --git a/lib/server/server.go b/lib/server/server.go
index 46179ff..a285ea2 100644
--- a/lib/server/server.go
+++ b/lib/server/server.go
@@ -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 {
diff --git a/static/css/app.css b/static/css/app.css
index 9565727..607f020 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -387,12 +387,51 @@ textarea:disabled {
:root { --base-font-size: 18px; --ui-scale: 1.88; }
}
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 1ms !important;
- animation-iteration-count: 1 !important;
- scroll-behavior: auto !important;
- }
+.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,
+ *::after {
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ scroll-behavior: auto !important;
+ }
}
diff --git a/templates/box.html b/templates/box.html
index d606d50..88c8175 100644
--- a/templates/box.html
+++ b/templates/box.html
@@ -29,7 +29,7 @@
Upload
{{ if .DownloadAll }}
-
Download Zip
+
Download Zip
{{ end }}