diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go index e42b47a..1c37bf0 100644 --- a/lib/boxstore/store.go +++ b/lib/boxstore/store.go @@ -2,6 +2,9 @@ package boxstore import ( "archive/zip" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" "encoding/json" "fmt" "io" @@ -12,6 +15,7 @@ import ( "path/filepath" "strings" "sync" + "time" "warpbox/lib/helpers" "warpbox/lib/models" @@ -24,6 +28,15 @@ const ( var manifestMu sync.Mutex +var retentionOptions = []models.RetentionOption{ + {Key: "10s", Label: "10 seconds", Seconds: 10}, + {Key: "10m", Label: "10 minutes", Seconds: 10 * 60}, + {Key: "1h", Label: "1 hour", Seconds: 60 * 60}, + {Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60}, + {Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60}, + {Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60}, +} + func NewBoxID() (string, error) { return helpers.RandomHexID(16) } @@ -32,6 +45,16 @@ func ValidBoxID(boxID string) bool { return helpers.ValidLowerHexID(boxID, 32) } +func RetentionOptions() []models.RetentionOption { + options := make([]models.RetentionOption, len(retentionOptions)) + copy(options, retentionOptions) + return options +} + +func DefaultRetentionOption() models.RetentionOption { + return retentionOptions[0] +} + func BoxPath(boxID string) string { return filepath.Join(UploadRoot, boxID) } @@ -44,6 +67,10 @@ func SafeBoxFilePath(boxID string, filename string) (string, bool) { return helpers.SafeChildPath(BoxPath(boxID), filename) } +func DeleteBox(boxID string) error { + return os.RemoveAll(BoxPath(boxID)) +} + func ListFiles(boxID string) ([]models.BoxFile, error) { if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 { files := make([]models.BoxFile, 0, len(manifest.Files)) @@ -57,12 +84,13 @@ func ListFiles(boxID string) ([]models.BoxFile, error) { return listCompletedFilesFromDisk(boxID) } -func CreateManifest(boxID string, requests []models.CreateBoxFileRequest) ([]models.BoxFile, error) { - usedNames := make(map[string]int, len(requests)) - files := make([]models.BoxFile, 0, len(requests)) +func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) { + retention := normalizeRetentionOption(request.RetentionKey) + usedNames := make(map[string]int, len(request.Files)) + files := make([]models.BoxFile, 0, len(request.Files)) - for _, request := range requests { - filename, ok := helpers.SafeFilename(request.Name) + for _, fileRequest := range request.Files { + filename, ok := helpers.SafeFilename(fileRequest.Name) if !ok { return nil, fmt.Errorf("Invalid filename") } @@ -81,13 +109,43 @@ func CreateManifest(boxID string, requests []models.CreateBoxFileRequest) ([]mod files = append(files, models.BoxFile{ ID: fileID, Name: filename, - Size: request.Size, + Size: fileRequest.Size, MimeType: mimeType, Status: models.FileStatusWait, }) } - manifest := models.BoxManifest{Files: files} + now := time.Now().UTC() + disableZip := false + if request.AllowZip != nil { + disableZip = !*request.AllowZip + } + + manifest := models.BoxManifest{ + Files: files, + CreatedAt: now, + RetentionKey: retention.Key, + RetentionLabel: retention.Label, + RetentionSecs: retention.Seconds, + DisableZip: disableZip, + } + + if password := strings.TrimSpace(request.Password); password != "" { + salt, err := helpers.RandomHexID(16) + if err != nil { + return nil, fmt.Errorf("Could not secure upload box") + } + + authToken, err := helpers.RandomHexID(16) + if err != nil { + return nil, fmt.Errorf("Could not secure upload box") + } + + manifest.PasswordSalt = salt + manifest.PasswordHash = passwordHash(salt, password) + manifest.AuthToken = authToken + } + if err := WriteManifest(boxID, manifest); err != nil { return nil, err } @@ -100,6 +158,36 @@ func CreateManifest(boxID string, requests []models.CreateBoxFileRequest) ([]mod return decoratedFiles, nil } +func IsExpired(manifest models.BoxManifest) bool { + return !manifest.ExpiresAt.IsZero() && time.Now().UTC().After(manifest.ExpiresAt) +} + +func IsPasswordProtected(manifest models.BoxManifest) bool { + return manifest.PasswordSalt != "" && manifest.PasswordHash != "" && manifest.AuthToken != "" +} + +func VerifyPassword(manifest models.BoxManifest, password string) bool { + if !IsPasswordProtected(manifest) { + return true + } + + expected := manifest.PasswordHash + actual := passwordHash(manifest.PasswordSalt, password) + return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 +} + +func VerifyAuthToken(manifest models.BoxManifest, token string) bool { + if !IsPasswordProtected(manifest) { + return true + } + + if token == "" { + return false + } + + return subtle.ConstantTimeCompare([]byte(manifest.AuthToken), []byte(token)) == 1 +} + func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) { if status != models.FileStatusWait && status != models.FileStatusWork && status != models.FileStatusReady && status != models.FileStatusFailed { return models.BoxFile{}, fmt.Errorf("Invalid file status") @@ -119,6 +207,7 @@ func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, } manifest.Files[index].Status = status + startRetentionIfTerminalUnlocked(&manifest) if err := writeManifestUnlocked(boxID, manifest); err != nil { return models.BoxFile{}, err } @@ -188,6 +277,7 @@ func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader) destination := filepath.Join(BoxPath(boxID), filename) if err := saveMultipartFile(file, destination); err != nil { manifest.Files[fileIndex].Status = models.FileStatusFailed + startRetentionIfTerminalUnlocked(&manifest) writeManifestUnlocked(boxID, manifest) return models.BoxFile{}, fmt.Errorf("Could not save uploaded file") } @@ -195,6 +285,7 @@ func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader) manifest.Files[fileIndex].Size = file.Size manifest.Files[fileIndex].MimeType = helpers.MimeTypeForFile(destination, filename) manifest.Files[fileIndex].Status = models.FileStatusReady + startRetentionIfTerminalUnlocked(&manifest) if err := writeManifestUnlocked(boxID, manifest); err != nil { return models.BoxFile{}, err } @@ -318,6 +409,7 @@ func reconcileManifest(boxID string) (models.BoxManifest, error) { } if changed { + startRetentionIfTerminalUnlocked(&manifest) if err := writeManifestUnlocked(boxID, manifest); err != nil { return manifest, err } @@ -370,6 +462,42 @@ func readManifestUnlocked(boxID string) (models.BoxManifest, error) { return manifest, nil } +func normalizeRetentionOption(key string) models.RetentionOption { + for _, option := range retentionOptions { + if option.Key == key { + return option + } + } + + return DefaultRetentionOption() +} + +func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) { + if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 { + return + } + + for _, file := range manifest.Files { + if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed { + return + } + } + + seconds := manifest.RetentionSecs + if seconds <= 0 { + seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds + } + + // Retention starts after uploads settle so slow or very large uploads do + // not expire before users get a real chance to open the box. + manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second) +} + +func passwordHash(salt string, password string) string { + sum := sha256.Sum256([]byte(salt + ":" + password)) + return hex.EncodeToString(sum[:]) +} + // Manifest writes are serialized because the browser can upload several files // concurrently into the same box. Without this lock, status updates can race. func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error { diff --git a/lib/models/models.go b/lib/models/models.go index 4cd7716..5ff3965 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -1,5 +1,7 @@ package models +import "time" + const ( FileStatusFailed = "failed" FileStatusReady = "complete" @@ -7,6 +9,12 @@ const ( FileStatusWork = "uploading" ) +type RetentionOption struct { + Key string `json:"key"` + Label string `json:"label"` + Seconds int64 `json:"seconds"` +} + type BoxFile struct { ID string `json:"id"` Name string `json:"name"` @@ -23,11 +31,23 @@ type BoxFile struct { } type BoxManifest struct { - Files []BoxFile `json:"files"` + Files []BoxFile `json:"files"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RetentionKey string `json:"retention_key"` + RetentionLabel string `json:"retention_label"` + RetentionSecs int64 `json:"retention_seconds"` + PasswordSalt string `json:"password_salt,omitempty"` + PasswordHash string `json:"password_hash,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + DisableZip bool `json:"disable_zip,omitempty"` } type CreateBoxRequest struct { - Files []CreateBoxFileRequest `json:"files"` + Files []CreateBoxFileRequest `json:"files"` + RetentionKey string `json:"retention_key"` + Password string `json:"password"` + AllowZip *bool `json:"allow_zip"` } type CreateBoxFileRequest struct { diff --git a/lib/routing/routes.go b/lib/routing/routes.go index 2e21d5f..e31c9ed 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -5,6 +5,8 @@ import "github.com/gin-gonic/gin" type Handlers struct { Index gin.HandlerFunc ShowBox gin.HandlerFunc + BoxLogin gin.HandlerFunc + BoxLoginPost gin.HandlerFunc BoxStatus gin.HandlerFunc DownloadBox gin.HandlerFunc DownloadFile gin.HandlerFunc @@ -19,11 +21,13 @@ func Register(router *gin.Engine, handlers Handlers) { router.GET("/", handlers.Index) router.GET("/box/:id", handlers.ShowBox) + router.GET("/box/:id/login", handlers.BoxLogin) router.GET("/box/:id/status", handlers.BoxStatus) router.GET("/box/:id/download", handlers.DownloadBox) router.GET("/box/:id/files/:filename", handlers.DownloadFile) router.POST("/box", handlers.CreateBox) + router.POST("/box/:id/login", handlers.BoxLoginPost) router.POST("/box/:id/files/:file_id/upload", handlers.ManifestFileUpload) router.POST("/box/:id/files/:file_id/status", handlers.FileStatusUpdate) diff --git a/lib/server/handlers.go b/lib/server/handlers.go index 42163fb..50c893b 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "time" "github.com/gin-gonic/gin" @@ -14,8 +15,13 @@ import ( "warpbox/lib/models" ) +const boxAuthCookiePrefix = "warpbox_box_" + func handleIndex(ctx *gin.Context) { - ctx.HTML(http.StatusOK, "index.html", gin.H{}) + ctx.HTML(http.StatusOK, "index.html", gin.H{ + "RetentionOptions": boxstore.RetentionOptions(), + "DefaultRetention": boxstore.DefaultRetentionOption().Key, + }) } func handleShowBox(ctx *gin.Context) { @@ -25,21 +31,96 @@ func handleShowBox(ctx *gin.Context) { return } + manifest, hasManifest, ok := authorizeBoxRequest(ctx, boxID, true) + if !ok { + return + } + files, err := boxstore.ListFiles(boxID) if err != nil { ctx.String(http.StatusNotFound, "Box not found") return } + downloadAll := "/box/" + boxID + "/download" + if hasManifest && manifest.DisableZip { + downloadAll = "" + } + ctx.HTML(http.StatusOK, "box.html", gin.H{ - "BoxID": boxID, - "Files": files, - "FileCount": len(files), - "DownloadAll": "/box/" + boxID + "/download", - "PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000), + "BoxID": boxID, + "Files": files, + "FileCount": len(files), + "DownloadAll": downloadAll, + "PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000), + "RetentionLabel": manifest.RetentionLabel, + "ExpiresAt": manifest.ExpiresAt, }) } +func handleBoxLogin(ctx *gin.Context) { + boxID := ctx.Param("id") + if !boxstore.ValidBoxID(boxID) { + ctx.String(http.StatusBadRequest, "Invalid box id") + return + } + + manifest, err := boxstore.ReadManifest(boxID) + if err != nil { + ctx.String(http.StatusNotFound, "Box not found") + return + } + + if boxstore.IsExpired(manifest) { + boxstore.DeleteBox(boxID) + ctx.String(http.StatusGone, "Box expired") + return + } + + if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) { + ctx.Redirect(http.StatusSeeOther, "/box/"+boxID) + return + } + + renderBoxLogin(ctx, boxID, "") +} + +func handleBoxLoginPost(ctx *gin.Context) { + boxID := ctx.Param("id") + if !boxstore.ValidBoxID(boxID) { + ctx.String(http.StatusBadRequest, "Invalid box id") + return + } + + manifest, err := boxstore.ReadManifest(boxID) + if err != nil { + ctx.String(http.StatusNotFound, "Box not found") + return + } + + if boxstore.IsExpired(manifest) { + boxstore.DeleteBox(boxID) + ctx.String(http.StatusGone, "Box expired") + return + } + + if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) { + renderBoxLogin(ctx, boxID, "The password was not accepted.") + return + } + + maxAge := 24 * 60 * 60 + if !manifest.ExpiresAt.IsZero() { + seconds := int(time.Until(manifest.ExpiresAt).Seconds()) + if seconds > 0 { + maxAge = seconds + } + } + + ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true) + ctx.Redirect(http.StatusSeeOther, "/box/"+boxID) +} + func handleBoxStatus(ctx *gin.Context) { boxID := ctx.Param("id") if !boxstore.ValidBoxID(boxID) { @@ -47,6 +128,10 @@ func handleBoxStatus(ctx *gin.Context) { return } + if _, _, ok := authorizeBoxRequest(ctx, boxID, false); !ok { + return + } + files, err := boxstore.ListFiles(boxID) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"}) @@ -63,6 +148,16 @@ func handleDownloadBox(ctx *gin.Context) { return } + manifest, hasManifest, ok := authorizeBoxRequest(ctx, boxID, true) + if !ok { + return + } + + if hasManifest && manifest.DisableZip { + ctx.String(http.StatusForbidden, "Zip download disabled for this box") + return + } + files, err := boxstore.ListFiles(boxID) if err != nil { ctx.String(http.StatusNotFound, "Box not found") @@ -95,6 +190,10 @@ func handleDownloadFile(ctx *gin.Context) { return } + if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized { + return + } + path, ok := boxstore.SafeBoxFilePath(boxID, filename) if !ok { ctx.String(http.StatusBadRequest, "Invalid file") @@ -127,7 +226,7 @@ func handleCreateBox(ctx *gin.Context) { return } - files, err := boxstore.CreateManifest(boxID, request.Files) + files, err := boxstore.CreateManifest(boxID, request) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -243,3 +342,48 @@ func handleLegacyUpload(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles}) } + +func authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) { + manifest, err := boxstore.ReadManifest(boxID) + if err != nil { + return models.BoxManifest{}, false, true + } + + if boxstore.IsExpired(manifest) { + boxstore.DeleteBox(boxID) + if wantsHTML { + ctx.String(http.StatusGone, "Box expired") + } else { + ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"}) + } + return manifest, true, false + } + + if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) { + if wantsHTML { + ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login") + } else { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"}) + } + return manifest, true, false + } + + return manifest, true, true +} + +func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool { + token, err := ctx.Cookie(boxAuthCookieName(boxID)) + return err == nil && boxstore.VerifyAuthToken(manifest, token) +} + +func boxAuthCookieName(boxID string) string { + return boxAuthCookiePrefix + boxID +} + +func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) { + ctx.HTML(http.StatusOK, "box_login.html", gin.H{ + "BoxID": boxID, + "BoxUser": "WarpBox\\" + boxID, + "ErrorMessage": errorMessage, + }) +} diff --git a/lib/server/server.go b/lib/server/server.go index 7ba2c66..5683202 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -14,6 +14,8 @@ func Run(addr string) error { routing.Register(router, routing.Handlers{ Index: handleIndex, ShowBox: handleShowBox, + BoxLogin: handleBoxLogin, + BoxLoginPost: handleBoxLoginPost, BoxStatus: handleBoxStatus, DownloadBox: handleDownloadBox, DownloadFile: handleDownloadFile, diff --git a/static/css/app.css b/static/css/app.css index 01e5478..5fbddb2 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -59,6 +59,7 @@ label[for], } input[type="text"], +input[type="password"], input[type="file"], textarea, [contenteditable="true"] { diff --git a/static/css/box.css b/static/css/box.css index d545d7a..e17dc3a 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -27,6 +27,19 @@ line-height: 13px; } +.box-meta { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + align-items: center; + height: 24px; + box-sizing: border-box; + padding: 0 8px 6px; + gap: 6px; + color: #333333; + font-size: 12px; + line-height: 12px; +} + .box-address code { min-width: 0; height: 22px; diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..10b7ec6 --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,127 @@ +.login-window { + width: 420px; + height: 248px; +} + +.login-form { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.login-panel { + flex: 1; + margin: 8px; + padding: 12px; + background: #c0c0c0; +} + +.login-alert { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 10px; + align-items: center; + min-height: 48px; + margin-bottom: 12px; + font-size: 13px; + line-height: 15px; +} + +.login-alert img { + width: 32px; + height: 32px; + object-fit: contain; + image-rendering: pixelated; +} + +.login-alert p { + margin: 0; +} + +.login-row { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; + line-height: 13px; +} + +.login-input { + width: 100%; + height: 24px; + box-sizing: border-box; + padding: 2px 5px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-family: inherit; + font-size: 13px; + line-height: 13px; +} + +.login-input[readonly] { + color: #555555; + background: #dfdfdf; +} + +.login-error { + margin: 2px 0 0 90px; + color: #800000; + font-size: 12px; + line-height: 12px; +} + +.login-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + height: 40px; + box-sizing: border-box; + padding: 0 8px 8px; +} + +.login-actions .win98-button { + text-decoration: none; +} + +.login-statusbar { + grid-template-columns: 1fr 96px; +} + +@media (max-width: 600px) { + main { + display: block; + min-height: 100dvh; + } + + .login-window { + width: 100vw; + height: 100dvh; + border: 0; + box-shadow: none; + } + + .login-titlebar { + height: 24px; + margin: 0; + } + + .login-panel { + margin: 8px 6px; + } + + .login-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .login-error { + margin-left: 0; + } +} diff --git a/static/css/upload.css b/static/css/upload.css index 9ca2514..78226ca 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -1,6 +1,6 @@ .upload-window { width: 520px; - height: 486px; + height: 566px; } .upload-form { @@ -21,7 +21,7 @@ .upload-dropzone { flex: 0 0 auto; - height: 118px; + height: 88px; box-sizing: border-box; display: flex; flex-direction: column; @@ -34,6 +34,76 @@ border: 1px dotted #000000; } +.upload-options { + flex: 0 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 10px; + box-sizing: border-box; + margin: 10px 0 0; + padding: 8px 8px 10px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + font-size: 12px; + line-height: 12px; +} + +.upload-options legend { + padding: 0 4px; + font-weight: bold; +} + +.upload-option-row { + grid-column: 1 / 3; + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + align-items: center; + gap: 6px; +} + +.upload-check-row { + display: flex; + align-items: center; + min-width: 0; + gap: 5px; + white-space: nowrap; +} + +.upload-check-row input { + width: 13px; + height: 13px; + margin: 0; +} + +.upload-select, +.upload-text-input { + width: 100%; + height: 22px; + box-sizing: border-box; + padding: 1px 4px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-family: inherit; + font-size: 12px; + line-height: 12px; +} + +.upload-text-input { + min-width: 0; +} + +.upload-text-input:disabled { + color: #808080; + background: #c0c0c0; +} + .upload-dropzone.is-dragging { background: #c7d8f2; outline: 2px solid #000078; @@ -119,7 +189,6 @@ min-height: 0; margin-top: 8px; overflow-y: auto; - color: #fff; border-top: 2px solid #808080; border-left: 2px solid #808080; border-right: 2px solid #ffffff; @@ -367,11 +436,20 @@ } .upload-dropzone { - height: 126px; - min-height: 126px; + height: 96px; + min-height: 96px; } .upload-result { grid-template-columns: 64px minmax(0, 1fr) 68px; } + + .upload-options { + grid-template-columns: 1fr; + } + + .upload-option-row, + .upload-text-input { + grid-column: 1; + } } diff --git a/static/js/app.js b/static/js/app.js index f1d4008..2abc5cc 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -10,6 +10,10 @@ const boxLink = document.querySelector("#upload-box-link"); const shareButton = document.querySelector("#upload-share-button"); const overallProgressBar = document.querySelector(".upload-overall-bar"); const overallProgressPercent = document.querySelector(".upload-overall-percent"); +const retentionSelect = document.querySelector("#upload-retention"); +const passwordEnabled = document.querySelector("#upload-password-enabled"); +const passwordInput = document.querySelector("#upload-password"); +const zipEnabled = document.querySelector("#upload-zip-enabled"); let selectedFiles = []; let statusTimer = null; @@ -194,6 +198,9 @@ async function createBox() { "Content-Type": "application/json", }, body: JSON.stringify({ + retention_key: retentionSelect ? retentionSelect.value : "10s", + password: passwordEnabled && passwordEnabled.checked && passwordInput ? passwordInput.value : "", + allow_zip: !zipEnabled || zipEnabled.checked, files: selectedFiles.map((selectedFile) => ({ name: selectedFile.file.name, size: selectedFile.file.size, @@ -307,6 +314,18 @@ if (fileInput) { }); } +if (passwordEnabled && passwordInput) { + passwordEnabled.addEventListener("change", () => { + passwordInput.disabled = !passwordEnabled.checked; + if (!passwordEnabled.checked) { + passwordInput.value = ""; + return; + } + + passwordInput.focus(); + }); +} + if (fileInput && dropzone) { dropzone.addEventListener("dragover", (event) => { event.preventDefault(); @@ -340,6 +359,12 @@ if (uploadForm) { return; } + if (passwordEnabled && passwordEnabled.checked && passwordInput && !passwordInput.value.trim()) { + updateStatus("Enter password"); + passwordInput.focus(); + return; + } + let completedCount = 0; const totalCount = selectedFiles.length; const statusPrefix = () => `${completedCount}/${totalCount}`; diff --git a/templates/box.html b/templates/box.html index 38fd685..3607327 100644 --- a/templates/box.html +++ b/templates/box.html @@ -32,7 +32,9 @@
/box/{{ .BoxID }}