docs: expand configuration docs for admin and BadgerDB

Update README to explain startup config precedence (defaults/env/admin overrides),
document admin/bootstrap and feature toggles, and clarify storage locations under
WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to
include new config and metastore packages.docs: expand configuration docs for admin and BadgerDB

Update README to explain startup config precedence (defaults/env/admin overrides),
document admin/bootstrap and feature toggles, and clarify storage locations under
WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to
include new config and metastore packages.
This commit is contained in:
2026-04-28 21:11:37 +03:00
parent fc3de58b5b
commit a5d6d69be0
27 changed files with 3499 additions and 97 deletions

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -17,21 +18,24 @@ import (
const boxAuthCookiePrefix = "warpbox_box_"
func handleIndex(ctx *gin.Context) {
func (app *App) handleIndex(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"RetentionOptions": boxstore.RetentionOptions(),
"DefaultRetention": boxstore.DefaultRetentionOption().Key,
"RetentionOptions": app.retentionOptions(),
"DefaultRetention": app.defaultRetentionOption().Key,
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
})
}
func handleShowBox(ctx *gin.Context) {
func (app *App) 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)
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
@@ -43,7 +47,7 @@ func handleShowBox(ctx *gin.Context) {
}
downloadAll := "/box/" + boxID + "/download"
if hasManifest && manifest.DisableZip {
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
downloadAll = ""
}
@@ -53,7 +57,7 @@ func handleShowBox(ctx *gin.Context) {
"FileCount": len(files),
"DownloadAll": downloadAll,
"ZipOnly": hasManifest && manifest.OneTimeDownload,
"PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000),
"PollMS": app.config.BoxPollIntervalMS,
"RetentionLabel": manifest.RetentionLabel,
"ExpiresAt": manifest.ExpiresAt,
})
@@ -122,14 +126,18 @@ func handleBoxLoginPost(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
}
func handleBoxStatus(ctx *gin.Context) {
func (app *App) handleBoxStatus(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
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 {
if _, _, ok := app.authorizeBoxRequest(ctx, boxID, false); !ok {
return
}
@@ -142,14 +150,19 @@ func handleBoxStatus(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "files": files})
}
func handleDownloadBox(ctx *gin.Context) {
func (app *App) 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 !app.config.ZipDownloadsEnabled {
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
@@ -200,6 +213,8 @@ func handleDownloadBox(ctx *gin.Context) {
if hasManifest && manifest.OneTimeDownload {
boxstore.DeleteBox(boxID)
} else if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
@@ -217,7 +232,7 @@ func allFilesComplete(files []models.BoxFile) bool {
return true
}
func handleDownloadFile(ctx *gin.Context) {
func (app *App) handleDownloadFile(ctx *gin.Context) {
boxID := ctx.Param("id")
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
if !boxstore.ValidBoxID(boxID) || !ok {
@@ -225,7 +240,7 @@ func handleDownloadFile(ctx *gin.Context) {
return
}
manifest, hasManifest, authorized := authorizeBoxRequest(ctx, boxID, true)
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
@@ -246,9 +261,12 @@ func handleDownloadFile(ctx *gin.Context) {
}
ctx.FileAttachment(path, filename)
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func handleDownloadThumbnail(ctx *gin.Context) {
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
@@ -256,7 +274,7 @@ func handleDownloadThumbnail(ctx *gin.Context) {
return
}
if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized {
if _, _, authorized := app.authorizeBoxRequest(ctx, boxID, true); !authorized {
return
}
@@ -275,7 +293,11 @@ func handleDownloadThumbnail(ctx *gin.Context) {
ctx.File(path)
}
func handleCreateBox(ctx *gin.Context) {
func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
@@ -292,6 +314,10 @@ func handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
return
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
files, err := boxstore.CreateManifest(boxID, request)
if err != nil {
@@ -302,7 +328,11 @@ func handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
}
func handleManifestFileUpload(ctx *gin.Context) {
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
@@ -316,6 +346,11 @@ func handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil {
@@ -327,7 +362,11 @@ func handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func handleFileStatusUpdate(ctx *gin.Context) {
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
@@ -350,7 +389,11 @@ func handleFileStatusUpdate(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"file": file})
}
func handleDirectBoxUpload(ctx *gin.Context) {
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
@@ -362,6 +405,10 @@ func handleDirectBoxUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveUpload(boxID, file)
if err != nil {
@@ -372,7 +419,11 @@ func handleDirectBoxUpload(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func handleLegacyUpload(ctx *gin.Context) {
func (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
form, err := ctx.MultipartForm()
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
@@ -384,6 +435,18 @@ func handleLegacyUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
return
}
totalSize := int64(0)
for _, file := range files {
if err := app.validateFileSize(file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize += file.Size
}
if err := app.validateBoxSize(totalSize); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
@@ -410,7 +473,7 @@ 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) {
func (app *App) 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
@@ -435,6 +498,12 @@ func authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models
return manifest, true, false
}
if app.config.RenewOnAccessEnabled {
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
manifest = renewed
}
}
return manifest, true, true
}
@@ -447,6 +516,155 @@ func boxAuthCookieName(boxID string) string {
return boxAuthCookiePrefix + boxID
}
func (app *App) requireAPI(ctx *gin.Context) bool {
if app.config.APIEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
return false
}
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
if app.config.GuestUploadsEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
return false
}
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
if request == nil {
return nil
}
if !app.retentionAllowed(request.RetentionKey) {
return fmt.Errorf("Retention option is not allowed")
}
if !app.config.ZipDownloadsEnabled {
allowZip := false
request.AllowZip = &allowZip
}
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
return fmt.Errorf("One-time downloads are disabled")
}
totalSize := int64(0)
for _, file := range request.Files {
if err := app.validateFileSize(file.Size); err != nil {
return err
}
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateIncomingFile(boxID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return nil
}
totalSize := size
for _, file := range files {
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return app.validateIncomingFile(boxID, size)
}
totalSize := int64(0)
found := false
for _, file := range manifest.Files {
if file.ID == fileID {
totalSize += size
found = true
continue
}
totalSize += file.Size
}
if !found {
totalSize += size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateFileSize(size int64) error {
if size < 0 {
return fmt.Errorf("File size cannot be negative")
}
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
return fmt.Errorf("File exceeds the global max file size")
}
return nil
}
func (app *App) validateBoxSize(size int64) error {
if size < 0 {
return fmt.Errorf("Box size cannot be negative")
}
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
return fmt.Errorf("Box exceeds the global max box size")
}
return nil
}
func (app *App) retentionAllowed(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return true
}
for _, option := range app.retentionOptions() {
if option.Key == key {
return true
}
}
return false
}
func (app *App) retentionOptions() []models.RetentionOption {
allOptions := boxstore.RetentionOptions()
options := make([]models.RetentionOption, 0, len(allOptions))
for _, option := range allOptions {
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
continue
}
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
continue
}
options = append(options, option)
}
if len(options) == 0 {
return allOptions[:1]
}
return options
}
func (app *App) defaultRetentionOption() models.RetentionOption {
options := app.retentionOptions()
for _, option := range options {
if option.Seconds == app.config.DefaultGuestExpirySeconds {
return option
}
}
return options[0]
}
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
"BoxID": boxID,