feat(boxstore): add retention options and box deletion support

Introduce configurable retention options and default selection, store
retention when creating manifests, and add a helper to delete box
directories to enable expiring/cleanup workflows. Update login and upload
styles (new login layout, taller upload window) to support the new UI.feat(boxstore): add retention options and box deletion support

Introduce configurable retention options and default selection, store
retention when creating manifests, and add a helper to delete box
directories to enable expiring/cleanup workflows. Update login and upload
styles (new login layout, taller upload window) to support the new UI.
This commit is contained in:
2026-04-27 18:18:53 +03:00
parent 2f37958c31
commit 041a9798a7
13 changed files with 654 additions and 22 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,
})
}

View File

@@ -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,