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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user