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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,7 @@ label[for],
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="file"],
|
||||
textarea,
|
||||
[contenteditable="true"] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
127
static/css/login.css
Normal file
127
static/css/login.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
|
||||
<div class="box-toolbar">
|
||||
<a class="win98-button box-toolbar-button" href="/">Upload</a>
|
||||
<a class="win98-button box-toolbar-button" href="{{ .DownloadAll }}">Download Zip</a>
|
||||
{{ if .DownloadAll }}
|
||||
<a class="win98-button box-toolbar-button" href="{{ .DownloadAll }}">Download Zip</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="box-address">
|
||||
@@ -40,6 +42,13 @@
|
||||
<code>/box/{{ .BoxID }}</code>
|
||||
</div>
|
||||
|
||||
{{ if .RetentionLabel }}
|
||||
<div class="box-meta">
|
||||
<span>Retention</span>
|
||||
<span>{{ .RetentionLabel }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="win98-panel box-panel" data-box-id="{{ .BoxID }}" data-poll-ms="{{ .PollMS }}">
|
||||
{{ if .Files }}
|
||||
<div class="box-file-grid" aria-label="Uploaded files">
|
||||
|
||||
60
templates/box_login.html
Normal file
60
templates/box_login.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WarpBox Login - {{ .BoxID }}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<link rel="stylesheet" href="/static/css/window.css">
|
||||
<link rel="stylesheet" href="/static/css/login.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<section class="win98-window login-window" aria-labelledby="login-window-title">
|
||||
<header class="win98-titlebar login-titlebar">
|
||||
<h1 id="login-window-title">Enter Network Password</h1>
|
||||
<div class="win98-window-controls" aria-hidden="true">
|
||||
<span class="win98-control">_</span>
|
||||
<span class="win98-control">□</span>
|
||||
<span class="win98-control">×</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="login-form" action="/box/{{ .BoxID }}/login" method="post">
|
||||
<div class="win98-panel login-panel">
|
||||
<div class="login-alert" role="alert">
|
||||
<img src="/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png" alt="" aria-hidden="true">
|
||||
<p>This WarpBox folder is password protected. Enter the password to view and download its files.</p>
|
||||
</div>
|
||||
|
||||
<label class="login-row" for="box-user">
|
||||
<span>User name</span>
|
||||
<input id="box-user" class="login-input" type="text" value="{{ .BoxUser }}" readonly>
|
||||
</label>
|
||||
|
||||
<label class="login-row" for="box-password">
|
||||
<span>Password</span>
|
||||
<input id="box-password" class="login-input" type="password" name="password" autocomplete="current-password" autofocus>
|
||||
</label>
|
||||
|
||||
{{ if .ErrorMessage }}
|
||||
<p class="login-error">{{ .ErrorMessage }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<footer class="login-actions">
|
||||
<button class="win98-button" type="submit">OK</button>
|
||||
<a class="win98-button" href="/">Cancel</a>
|
||||
</footer>
|
||||
|
||||
<div class="win98-statusbar login-statusbar">
|
||||
<span>Protected folder</span>
|
||||
<span>WarpBox</span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,6 +39,27 @@
|
||||
|
||||
<input id="file-upload" class="upload-input" type="file" name="files" multiple>
|
||||
|
||||
<fieldset class="upload-options">
|
||||
<legend>Box options</legend>
|
||||
<label class="upload-option-row" for="upload-retention">
|
||||
<span>Keep files</span>
|
||||
<select id="upload-retention" class="upload-select" name="retention">
|
||||
{{ range .RetentionOptions }}
|
||||
<option value="{{ .Key }}" {{ if eq .Key $.DefaultRetention }}selected{{ end }}>{{ .Label }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</label>
|
||||
<label class="upload-check-row" for="upload-password-enabled">
|
||||
<input id="upload-password-enabled" type="checkbox">
|
||||
<span>Password protect this box</span>
|
||||
</label>
|
||||
<input id="upload-password" class="upload-text-input" type="password" autocomplete="new-password" placeholder="Password" disabled>
|
||||
<label class="upload-check-row" for="upload-zip-enabled">
|
||||
<input id="upload-zip-enabled" type="checkbox" checked>
|
||||
<span>Allow Download Zip</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="upload-details">
|
||||
<span class="upload-detail-label">Selected Files</span>
|
||||
<span id="upload-file-count" class="upload-file-count">0 files</span>
|
||||
|
||||
Reference in New Issue
Block a user