Introduce a `one-time` retention option and persist it on the manifest as `one_time_download`. One-time download boxes bypass retention expiry scheduling, force zip downloads, and reject download attempts until all files are complete to prevent partial retrievals.feat(boxstore): add one-time download retention mode Introduce a `one-time` retention option and persist it on the manifest as `one_time_download`. One-time download boxes bypass retention expiry scheduling, force zip downloads, and reject download attempts until all files are complete to prevent partial retrievals.
457 lines
11 KiB
Go
457 lines
11 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,
|
|
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
|
"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
|
|
}
|
|
if hasManifest && manifest.OneTimeDownload && !allFilesComplete(files) {
|
|
ctx.String(http.StatusConflict, "Box is not ready yet")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
if err := zipWriter.Close(); err != nil {
|
|
zipClosed = true
|
|
ctx.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
zipClosed = true
|
|
|
|
if hasManifest && manifest.OneTimeDownload {
|
|
boxstore.DeleteBox(boxID)
|
|
}
|
|
}
|
|
|
|
func allFilesComplete(files []models.BoxFile) bool {
|
|
if len(files) == 0 {
|
|
return false
|
|
}
|
|
|
|
for _, file := range files {
|
|
if !file.IsComplete {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
manifest, hasManifest, authorized := 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
|
|
}
|
|
|
|
ctx.FileAttachment(path, filename)
|
|
}
|
|
|
|
func 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 := 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 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,
|
|
})
|
|
}
|