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 }}