package server import ( "archive/zip" "fmt" "io" "net/http" "os" "strings" "sync" "time" "github.com/gin-gonic/gin" "warpbox/lib/boxstore" "warpbox/lib/helpers" "warpbox/lib/models" ) const boxAuthCookiePrefix = "warpbox_box_" var oneTimeDownloadLocks sync.Map func formatBrowserTime(value time.Time) string { if value.IsZero() { return "" } return value.UTC().Format(time.RFC3339) } func (app *App) handleIndex(ctx *gin.Context) { ctx.HTML(http.StatusOK, "index.html", gin.H{ "RetentionOptions": app.retentionOptions(), "DefaultRetention": app.defaultRetentionOption().Key, "UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled, "MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes, "MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes, }) } func (app *App) handleShowBox(ctx *gin.Context) { boxID := ctx.Param("id") if !boxstore.ValidBoxID(boxID) { ctx.String(http.StatusBadRequest, "Invalid box id") return } manifest, hasManifest, ok := app.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 !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip { downloadAll = "" } ctx.HTML(http.StatusOK, "box.html", gin.H{ "BoxID": boxID, "Files": files, "FileCount": len(files), "DownloadAll": downloadAll, "ZipOnly": hasManifest && manifest.OneTimeDownload, "PollMS": app.config.BoxPollIntervalMS, "RetentionLabel": manifest.RetentionLabel, "ExpiresAt": manifest.ExpiresAt, "ExpiresAtISO": formatBrowserTime(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 (app *App) handleBoxStatus(ctx *gin.Context) { if !app.requireAPI(ctx) { return } boxID := ctx.Param("id") if !boxstore.ValidBoxID(boxID) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) return } manifest, _, ok := app.authorizeBoxRequest(ctx, boxID, false) if !ok { return } files, err := boxstore.ListFiles(boxID) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"}) return } ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files}) } func (app *App) handleDownloadBox(ctx *gin.Context) { boxID := ctx.Param("id") if !boxstore.ValidBoxID(boxID) { ctx.String(http.StatusBadRequest, "Invalid box id") return } if !app.config.ZipDownloadsEnabled { ctx.String(http.StatusForbidden, "Zip downloads are disabled") return } manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true) if !ok { return } if hasManifest && manifest.OneTimeDownload { app.handleOneTimeDownloadBox(ctx, boxID) 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") return } if !app.writeBoxZip(ctx, boxID, files) { return } if hasManifest && app.config.RenewOnDownloadEnabled { boxstore.RenewManifest(boxID, manifest.RetentionSecs) } } func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) { lock := oneTimeDownloadLock(boxID) lock.Lock() defer lock.Unlock() defer oneTimeDownloadLocks.Delete(boxID) manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true) if !ok { return } if !hasManifest || !manifest.OneTimeDownload { ctx.String(http.StatusNotFound, "Box not found") return } files, err := boxstore.ListFiles(boxID) if err != nil { ctx.String(http.StatusNotFound, "Box not found") return } if !allFilesComplete(files) { ctx.String(http.StatusConflict, "Box is not ready yet") return } if !app.writeBoxZip(ctx, boxID, files) { return } boxstore.DeleteBox(boxID) } func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool { ctx.Header("Content-Type", "application/zip") ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID)) zipWriter := zip.NewWriter(ctx.Writer) zipClosed := false defer func() { if !zipClosed { zipWriter.Close() } }() for _, file := range files { if !file.IsComplete { continue } if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil { ctx.Status(http.StatusInternalServerError) return false } } if err := zipWriter.Close(); err != nil { zipClosed = true ctx.Status(http.StatusInternalServerError) return false } zipClosed = true return true } func oneTimeDownloadLock(boxID string) *sync.Mutex { lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{}) return lock.(*sync.Mutex) } func allFilesComplete(files []models.BoxFile) bool { if len(files) == 0 { return false } for _, file := range files { if !file.IsComplete { return false } } return true } func (app *App) handleDownloadFile(ctx *gin.Context) { boxID := ctx.Param("id") filename, ok := helpers.SafeFilename(ctx.Param("filename")) if !boxstore.ValidBoxID(boxID) || !ok { ctx.String(http.StatusBadRequest, "Invalid file") return } manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true) if !authorized { return } if hasManifest && manifest.OneTimeDownload { ctx.String(http.StatusForbidden, "Individual downloads disabled for this box") return } path, ok := boxstore.SafeBoxFilePath(boxID, filename) if !ok { ctx.String(http.StatusBadRequest, "Invalid file") return } if _, err := os.Stat(path); err != nil { ctx.String(http.StatusNotFound, "File not found") return } if !boxstore.IsSafeRegularBoxFile(boxID, filename) { ctx.String(http.StatusBadRequest, "Invalid file") return } ctx.FileAttachment(path, filename) if hasManifest && app.config.RenewOnDownloadEnabled { boxstore.RenewManifest(boxID, manifest.RetentionSecs) } } func (app *App) handleDownloadThumbnail(ctx *gin.Context) { boxID := ctx.Param("id") fileID := ctx.Param("file_id") if !boxstore.ValidBoxID(boxID) { ctx.String(http.StatusBadRequest, "Invalid box id") return } if _, _, authorized := app.authorizeBoxRequest(ctx, boxID, true); !authorized { return } path, ok := boxstore.ThumbnailFilePath(boxID, fileID) if !ok { ctx.String(http.StatusBadRequest, "Invalid thumbnail") return } if _, err := os.Stat(path); err != nil { ctx.String(http.StatusNotFound, "Thumbnail not found") return } ctx.Header("Content-Type", "image/jpeg") ctx.File(path) } func (app *App) handleCreateBox(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } app.limitRequestBody(ctx) boxID, err := boxstore.NewBoxID() if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"}) return } if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) return } var request models.CreateBoxRequest if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"}) return } if err := app.validateCreateBoxRequest(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } files, err := boxstore.CreateManifest(boxID, request) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files}) } func (app *App) handleManifestFileUpload(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } app.limitRequestBody(ctx) boxID := ctx.Param("id") fileID := ctx.Param("file_id") if !boxstore.ValidBoxID(boxID) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) return } file, err := ctx.FormFile("file") if err != nil { boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) return } if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil { boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file) if err != nil { boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile}) } func (app *App) handleFileStatusUpdate(ctx *gin.Context) { if !app.requireAPI(ctx) { return } app.limitRequestBody(ctx) boxID := ctx.Param("id") fileID := ctx.Param("file_id") if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"}) return } var request models.UpdateFileStatusRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"}) return } if request.Status == models.FileStatusReady { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"}) return } if err := app.rejectExpiredManifestBox(boxID); err != nil { ctx.JSON(http.StatusGone, gin.H{"error": err.Error()}) return } file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, gin.H{"file": file}) } func (app *App) handleDirectBoxUpload(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } app.limitRequestBody(ctx) boxID := ctx.Param("id") if !boxstore.ValidBoxID(boxID) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) return } file, err := ctx.FormFile("file") if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) return } if err := app.validateIncomingFile(boxID, file.Size); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } savedFile, err := boxstore.SaveUpload(boxID, file) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile}) } func (app *App) handleLegacyUpload(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } app.limitRequestBody(ctx) form, err := ctx.MultipartForm() if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"}) return } files := form.File["files"] if len(files) == 0 { ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"}) return } totalSize := int64(0) for _, file := range files { if err := app.validateFileSize(file.Size); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } totalSize += file.Size } if err := app.validateBoxSize(totalSize); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } boxID, err := boxstore.NewBoxID() if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"}) return } if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) return } retentionKey := strings.TrimSpace(ctx.PostForm("retention_key")) if retentionKey == "" { retentionKey = strings.TrimSpace(ctx.PostForm("retention")) } allowZip := true if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") { allowZip = false } request := models.CreateBoxRequest{ RetentionKey: retentionKey, Password: ctx.PostForm("password"), AllowZip: &allowZip, Files: make([]models.CreateBoxFileRequest, 0, len(files)), } for _, file := range files { request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size}) } if err := app.validateCreateBoxRequest(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } manifestFiles, err := boxstore.CreateManifest(boxID, request) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } savedFiles := make([]models.BoxFile, 0, len(files)) for index, file := range files { savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file) if err != nil { _, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } savedFiles = append(savedFiles, savedFile) } ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles}) } func (app *App) 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 } if app.config.RenewOnAccessEnabled { if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil { manifest = renewed } } 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 (app *App) requireAPI(ctx *gin.Context) bool { if app.config.APIEnabled { return true } ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"}) return false } func (app *App) requireGuestUploads(ctx *gin.Context) bool { if app.config.GuestUploadsEnabled { return true } ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"}) return false } func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error { if request == nil { return nil } if !app.retentionAllowed(request.RetentionKey) { return fmt.Errorf("Retention option is not allowed") } if !app.config.ZipDownloadsEnabled { allowZip := false request.AllowZip = &allowZip } if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled { return fmt.Errorf("One-time downloads are disabled") } totalSize := int64(0) for _, file := range request.Files { if err := app.validateFileSize(file.Size); err != nil { return err } totalSize += file.Size } return app.validateBoxSize(totalSize) } func (app *App) validateIncomingFile(boxID string, size int64) error { if err := app.validateFileSize(size); err != nil { return err } if app.config.GlobalMaxBoxSizeBytes <= 0 { return nil } files, err := boxstore.ListFiles(boxID) if err != nil { return nil } totalSize := size for _, file := range files { totalSize += file.Size } return app.validateBoxSize(totalSize) } func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error { if err := app.validateFileSize(size); err != nil { return err } manifest, err := boxstore.ReadManifest(boxID) if err != nil { return app.validateIncomingFile(boxID, size) } if boxstore.IsExpired(manifest) { _ = boxstore.DeleteBox(boxID) return fmt.Errorf("Box expired") } if app.config.GlobalMaxBoxSizeBytes <= 0 { return nil } totalSize := int64(0) found := false for _, file := range manifest.Files { if file.ID == fileID { totalSize += size found = true continue } totalSize += file.Size } if !found { totalSize += size } return app.validateBoxSize(totalSize) } func (app *App) validateFileSize(size int64) error { if size < 0 { return fmt.Errorf("File size cannot be negative") } if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes { return fmt.Errorf("File exceeds the global max file size") } return nil } func (app *App) validateBoxSize(size int64) error { if size < 0 { return fmt.Errorf("Box size cannot be negative") } if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes { return fmt.Errorf("Box exceeds the global max box size") } return nil } func (app *App) rejectExpiredManifestBox(boxID string) error { manifest, err := boxstore.ReadManifest(boxID) if err != nil { return nil } if !boxstore.IsExpired(manifest) { return nil } _ = boxstore.DeleteBox(boxID) return fmt.Errorf("Box expired") } func (app *App) limitRequestBody(ctx *gin.Context) { limit := app.maxRequestBodyBytes() if limit <= 0 { return } ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit) } func (app *App) maxRequestBodyBytes() int64 { limit := app.config.GlobalMaxBoxSizeBytes if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit { limit = app.config.GlobalMaxFileSizeBytes } if limit <= 0 { return 0 } return limit + 10*1024*1024 } func (app *App) retentionAllowed(key string) bool { key = strings.TrimSpace(key) if key == "" { return true } for _, option := range app.retentionOptions() { if option.Key == key { return true } } return false } func (app *App) retentionOptions() []models.RetentionOption { allOptions := boxstore.RetentionOptions() options := make([]models.RetentionOption, 0, len(allOptions)) for _, option := range allOptions { if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled { continue } if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds { continue } options = append(options, option) } if len(options) == 0 { return allOptions[:1] } return options } func (app *App) defaultRetentionOption() models.RetentionOption { options := app.retentionOptions() for _, option := range options { if option.Seconds == app.config.DefaultGuestExpirySeconds { return option } } return options[0] } 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, }) }