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.
390 lines
9.6 KiB
Go
390 lines
9.6 KiB
Go
package server
|
|
|
|
import (
|
|
"archive/zip"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"warpbox/lib/boxstore"
|
|
"warpbox/lib/helpers"
|
|
"warpbox/lib/models"
|
|
)
|
|
|
|
const boxAuthCookiePrefix = "warpbox_box_"
|
|
|
|
func handleIndex(ctx *gin.Context) {
|
|
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
|
"RetentionOptions": boxstore.RetentionOptions(),
|
|
"DefaultRetention": boxstore.DefaultRetentionOption().Key,
|
|
})
|
|
}
|
|
|
|
func handleShowBox(ctx *gin.Context) {
|
|
boxID := ctx.Param("id")
|
|
if !boxstore.ValidBoxID(boxID) {
|
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
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": 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) {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
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"})
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "files": files})
|
|
}
|
|
|
|
func handleDownloadBox(ctx *gin.Context) {
|
|
boxID := ctx.Param("id")
|
|
if !boxstore.ValidBoxID(boxID) {
|
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
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")
|
|
return
|
|
}
|
|
|
|
ctx.Header("Content-Type", "application/zip")
|
|
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
|
|
|
zipWriter := zip.NewWriter(ctx.Writer)
|
|
defer 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
|
|
}
|
|
}
|
|
}
|
|
|
|
func 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
|
|
}
|
|
|
|
if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized {
|
|
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
|
|
}
|
|
|
|
ctx.FileAttachment(path, filename)
|
|
}
|
|
|
|
func handleCreateBox(ctx *gin.Context) {
|
|
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
|
|
}
|
|
|
|
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 handleManifestFileUpload(ctx *gin.Context) {
|
|
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
|
|
}
|
|
|
|
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 handleFileStatusUpdate(ctx *gin.Context) {
|
|
boxID := ctx.Param("id")
|
|
fileID := ctx.Param("file_id")
|
|
if !boxstore.ValidBoxID(boxID) {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
return
|
|
}
|
|
|
|
var request models.UpdateFileStatusRequest
|
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
|
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 handleDirectBoxUpload(ctx *gin.Context) {
|
|
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
|
|
}
|
|
|
|
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 handleLegacyUpload(ctx *gin.Context) {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
savedFiles := make([]models.BoxFile, 0, len(files))
|
|
for _, file := range files {
|
|
savedFile, err := boxstore.SaveUpload(boxID, file)
|
|
if err != nil {
|
|
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 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,
|
|
})
|
|
}
|