feat: add admin console, cleanup, and thumbnail workers

- Implement a token-authenticated admin console at `/admin` with overview metrics and file management.
- Add a background worker to periodically clean up expired boxes based on `WARPBOX_CLEANUP_EVERY`.
- Add a background worker to generate image and video thumbnails based on `WARPBOX_THUMBNAIL_EVERY`.
- Update file storage paths to use `@each@` and `@thumb@` prefixes to separate original files from thumbnails.
- Add severity fields to startup logs and update configuration templates.
This commit is contained in:
2026-05-25 16:52:57 +03:00
parent e12878887c
commit 26619bacbc
28 changed files with 1576 additions and 178 deletions

View File

@@ -3,6 +3,9 @@ WARPBOX_ENV=development
WARPBOX_ADDR=:8080
WARPBOX_BASE_URL=http://localhost:8080
WARPBOX_DATA_DIR=./data
WARPBOX_ADMIN_TOKEN=change-me
WARPBOX_CLEANUP_EVERY=1h
WARPBOX_THUMBNAIL_EVERY=1m
WARPBOX_MAX_UPLOAD_SIZE_MB=2048
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s

View File

@@ -16,6 +16,8 @@ Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
The dev script resolves that path from the repository root.
The basic admin console is available at `/admin`. Set `WARPBOX_ADMIN_TOKEN` and use that value to sign in.
For one-off Go commands, run them from the backend module:
```bash
@@ -39,11 +41,20 @@ go run ./cmd/warpbox
- `scripts/env/dev.env.example` - tracked development environment template.
- `scripts/env/dev.env` - local development environment, ignored by git.
## Stage 2 Operator Tools
- `/admin/login` - token-based admin login.
- `/admin` - overview metrics: boxes, files, storage, recent uploads, protected/expired boxes.
- `/admin/files` - recent upload table with view and delete actions.
- Expired boxes are cleaned on startup and then every `WARPBOX_CLEANUP_EVERY`.
- Missing image/video thumbnails are generated in a background worker every `WARPBOX_THUMBNAIL_EVERY`.
## Runtime Data
Warpbox keeps local runtime data under the configured data directory:
- `data/files/{box_id}/{file_id}.ext` - uploaded file contents.
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents.
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available.
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.

View File

@@ -30,13 +30,13 @@ func main() {
server, err := httpserver.New(cfg, logger)
if err != nil {
logger.Error("failed to create server", "source", "startup", "error", err.Error())
logger.Error("failed to create server", "source", "startup", "severity", "error", "error", err.Error())
os.Exit(1)
}
errs := make(chan error, 1)
go func() {
logger.Info("warpbox server starting", "source", "startup", "addr", cfg.Addr, "env", cfg.Environment)
logger.Info("warpbox server starting", "source", "startup", "severity", "dev", "addr", cfg.Addr, "env", cfg.Environment)
errs <- server.ListenAndServe()
}()
@@ -46,18 +46,18 @@ func main() {
select {
case err := <-errs:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server stopped unexpectedly", "source", "startup", "error", err.Error())
logger.Error("server stopped unexpectedly", "source", "startup", "severity", "error", "error", err.Error())
os.Exit(1)
}
case sig := <-shutdown:
logger.Info("shutdown signal received", "source", "startup", "signal", sig.String())
logger.Info("shutdown signal received", "source", "startup", "severity", "dev", "signal", sig.String())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "source", "startup", "error", err.Error())
logger.Error("graceful shutdown failed", "source", "startup", "severity", "error", "error", err.Error())
os.Exit(1)
}
logger.Info("server stopped", "source", "startup")
logger.Info("server stopped", "source", "startup", "severity", "dev")
}
}

View File

@@ -2,6 +2,9 @@ module warpbox.dev/backend
go 1.26
require go.etcd.io/bbolt v1.4.3
require (
go.etcd.io/bbolt v1.4.3
golang.org/x/image v0.41.0
)
require golang.org/x/sys v0.29.0 // indirect

View File

@@ -6,6 +6,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=

View File

@@ -11,32 +11,38 @@ import (
)
type Config struct {
AppName string
Environment string
Addr string
BaseURL string
DataDir string
StaticDir string
TemplateDir string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxUploadSize int64
AppName string
Environment string
Addr string
BaseURL string
DataDir string
AdminToken string
StaticDir string
TemplateDir string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
CleanupEvery time.Duration
ThumbnailEvery time.Duration
MaxUploadSize int64
}
func Load() (Config, error) {
cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
}
if cfg.BaseURL == "" {

View File

@@ -0,0 +1,198 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
const adminCookieName = "warpbox_admin"
type adminPageData struct {
Stats services.AdminStats
Boxes []adminBoxView
Error string
}
type adminBoxView struct {
ID string
CreatedAt string
ExpiresAt string
FileCount int
TotalSizeLabel string
DownloadCount int
MaxDownloads int
Protected bool
Expired bool
}
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
if a.isAdmin(r) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
a.renderAdminLogin(w, http.StatusOK, "")
}
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.renderAdminLogin(w, http.StatusBadRequest, "Unable to read login form.")
return
}
if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken {
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301)
a.renderAdminLogin(w, http.StatusUnauthorized, "Invalid admin token.")
return
}
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: adminCookieValue(a.cfg.AdminToken),
Path: "/admin",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().Add(12 * time.Hour),
})
a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: "",
Path: "/admin",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
boxes, err := a.adminBoxes(8)
if err != nil {
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
return
}
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
Title: "Admin overview",
Description: "Warpbox admin overview.",
Data: adminPageData{
Stats: stats,
Boxes: boxes,
},
})
}
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
boxes, err := a.adminBoxes(100)
if err != nil {
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return
}
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
Title: "Admin files",
Description: "Manage Warpbox uploads.",
Data: adminPageData{
Stats: stats,
Boxes: boxes,
},
})
}
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
boxID := r.PathValue("boxID")
if err := a.uploadService.DeleteBox(boxID); err != nil {
a.logger.Warn("admin delete failed", "source", "admin", "severity", "warn", "code", 4302, "box_id", boxID, "error", err.Error())
http.Error(w, "unable to delete box", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/files", http.StatusSeeOther)
}
func (a *App) renderAdminLogin(w http.ResponseWriter, status int, message string) {
a.renderer.Render(w, status, "admin_login.html", web.PageData{
Title: "Admin login",
Description: "Sign in to the Warpbox admin console.",
Data: adminPageData{
Error: message,
},
})
}
func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
boxes, err := a.uploadService.AdminBoxes(limit)
if err != nil {
return nil, err
}
rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes {
rows = append(rows, adminBoxView{
ID: box.ID,
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
FileCount: box.FileCount,
TotalSizeLabel: box.TotalSizeLabel,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: box.Protected,
Expired: box.Expired,
})
}
return rows, nil
}
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if a.isAdmin(r) {
return true
}
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return false
}
func (a *App) isAdmin(r *http.Request) bool {
if a.cfg.AdminToken == "" {
return false
}
cookie, err := r.Cookie(adminCookieName)
if err != nil {
return false
}
return cookie.Value == adminCookieValue(a.cfg.AdminToken)
}
func adminCookieValue(token string) string {
sum := sha256.Sum256([]byte("warpbox-admin:" + token))
return hex.EncodeToString(sum[:])
}

View File

@@ -27,9 +27,18 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /admin/login", a.AdminLogin)
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
mux.HandleFunc("GET /admin", a.AdminDashboard)
mux.HandleFunc("GET /admin/files", a.AdminFiles)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)
mux.HandleFunc("POST /api/v1/upload", a.Upload)

View File

@@ -5,9 +5,12 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
@@ -15,6 +18,9 @@ type downloadPageData struct {
Box boxView
Files []fileView
ZipURL string
Locked bool
Obfuscated bool
CanPreview bool
DownloadCount int
MaxDownloads int
ExpiresLabel string
@@ -25,11 +31,21 @@ type boxView struct {
}
type fileView struct {
ID string
Name string
Size string
ContentType string
URL string
ID string
Name string
Size string
ContentType string
PreviewKind string
URL string
DownloadURL string
ThumbnailURL string
}
type previewPageData struct {
Box boxView
File fileView
Locked bool
DownloadURL string
}
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
@@ -39,7 +55,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.uploadService.CanDownload(box); err != nil {
a.renderer.Render(w, http.StatusForbidden, "download.gohtml", web.PageData{
a.renderer.Render(w, http.StatusForbidden, "download.html", web.PageData{
Title: "Download unavailable",
Description: "This Warpbox link is no longer available.",
Data: downloadPageData{
@@ -49,25 +65,24 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
})
return
}
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
files := make([]fileView, 0, len(box.Files))
for _, file := range box.Files {
files = append(files, fileView{
ID: file.ID,
Name: file.Name,
Size: helpers.FormatBytes(file.Size),
ContentType: file.ContentType,
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
})
if !(locked && box.Obfuscate) {
for _, file := range box.Files {
files = append(files, a.fileView(box, file))
}
}
a.renderer.Render(w, http.StatusOK, "download.gohtml", web.PageData{
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{
Title: "Download files",
Description: "Download files shared through Warpbox.",
Data: downloadPageData{
Box: boxView{ID: box.ID},
Files: files,
ZipURL: fmt.Sprintf("/d/%s/zip", box.ID),
Locked: locked,
Obfuscated: box.Obfuscate,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
@@ -76,22 +91,114 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
}
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
view := a.fileView(box, file)
title := file.Name
description := fmt.Sprintf("%s shared via Warpbox", helpers.FormatBytes(file.Size))
imageURL := absoluteURL(r, view.ThumbnailURL)
if locked && box.Obfuscate {
title = "Protected Warpbox file"
description = "This shared file is password protected."
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
}
a.renderer.Render(w, http.StatusOK, "preview.html", web.PageData{
Title: title,
Description: description,
ImageURL: imageURL,
Data: previewPageData{
Box: boxView{ID: box.ID},
File: view,
Locked: locked,
DownloadURL: view.DownloadURL,
},
})
}
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
}
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
return
}
path := a.uploadService.ThumbnailPath(box, file)
if path == "" {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
return
}
http.ServeFile(w, r, path)
}
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
return
}
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: unlockCookieName(box.ID),
Value: a.uploadService.UnlockToken(box),
Path: "/d/" + box.ID,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: box.ExpiresAt,
})
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
}
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return services.Box{}, services.File{}, false
}
if err := a.uploadService.CanDownload(box); err != nil {
http.Error(w, err.Error(), statusForDownloadError(err))
return
return services.Box{}, services.File{}, false
}
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
if err != nil {
http.NotFound(w, r)
return
return services.Box{}, services.File{}, false
}
return box, file, true
}
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
path := a.uploadService.FilePath(box, file)
source, err := os.Open(path)
if err != nil {
@@ -107,11 +214,13 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", file.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
if attachment {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
}
http.ServeContent(w, r, file.Name, stat.ModTime(), source)
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record file download", "source", "download", "code", 4002, "box_id", box.ID, "error", err.Error())
a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error())
}
}
@@ -125,16 +234,59 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), statusForDownloadError(err))
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
if err := a.uploadService.WriteZip(w, box); err != nil {
a.logger.Error("zip download failed", "source", "download", "code", 5002, "box_id", box.ID, "error", err.Error())
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
return
}
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record zip download", "source", "download", "code", 4003, "box_id", box.ID, "error", err.Error())
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
}
}
func (a *App) fileView(box services.Box, file services.File) fileView {
return fileView{
ID: file.ID,
Name: file.Name,
Size: helpers.FormatBytes(file.Size),
ContentType: file.ContentType,
PreviewKind: file.PreviewKind,
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
}
}
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
if !a.uploadService.IsProtected(box) {
return true
}
cookie, err := r.Cookie(unlockCookieName(box.ID))
if err != nil {
return false
}
return cookie.Value == a.uploadService.UnlockToken(box)
}
func unlockCookieName(boxID string) string {
return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
}
func absoluteURL(r *http.Request, path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return fmt.Sprintf("%s://%s%s", scheme, r.Host, path)
}

View File

@@ -11,7 +11,7 @@ type homeData struct {
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "home.gohtml", web.PageData{
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files",
Description: "Upload and share files through a self-hosted Warpbox instance.",
Data: homeData{

View File

@@ -18,11 +18,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
files := r.MultipartForm.File["file"]
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")),
MaxDownloads: parseInt(r.FormValue("max_downloads")),
MaxDays: parseInt(r.FormValue("max_days")),
MaxDownloads: parseInt(r.FormValue("max_downloads")),
Password: r.FormValue("password"),
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
})
if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "code", 4001, "error", err.Error())
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}

View File

@@ -3,6 +3,7 @@ package httpserver
import (
"log/slog"
"net/http"
"time"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/handlers"
@@ -21,6 +22,8 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
if err != nil {
return nil, err
}
stopCleanup := startCleanup(uploadService, cfg.CleanupEvery, logger)
stopThumbnails := startThumbnails(uploadService, cfg.ThumbnailEvery, logger)
app := handlers.NewApp(cfg, logger, renderer, uploadService)
router := http.NewServeMux()
@@ -43,10 +46,81 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
IdleTimeout: cfg.IdleTimeout,
}
server.RegisterOnShutdown(func() {
stopCleanup()
stopThumbnails()
if err := uploadService.Close(); err != nil {
logger.Error("failed to close upload service", "source", "shutdown", "error", err.Error())
logger.Error("failed to close upload service", "source", "shutdown", "severity", "error", "error", err.Error())
}
})
return server, nil
}
func startCleanup(uploadService *services.UploadService, interval time.Duration, logger *slog.Logger) func() {
if interval <= 0 {
return func() {}
}
stop := make(chan struct{})
go func() {
if cleaned, err := uploadService.CleanupExpired(); err != nil {
logger.Warn("initial cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4201, "error", err.Error())
} else if cleaned > 0 {
logger.Info("initial cleanup complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if _, err := uploadService.CleanupExpired(); err != nil {
logger.Warn("scheduled cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4202, "error", err.Error())
}
case <-stop:
return
}
}
}()
return func() {
close(stop)
}
}
func startThumbnails(uploadService *services.UploadService, interval time.Duration, logger *slog.Logger) func() {
if interval <= 0 {
return func() {}
}
stop := make(chan struct{})
run := func(source string) {
result, err := uploadService.GenerateMissingThumbnails()
if err != nil {
logger.Warn("thumbnail job failed", "source", "thumbnail", "severity", "warn", "code", 4203, "error", err.Error())
return
}
if result.Generated > 0 || result.Failed > 0 {
logger.Info("thumbnail job run", "source", source, "severity", "user_activity", "code", 2204, "generated", result.Generated, "failed", result.Failed)
}
}
go func() {
run("thumbnail")
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
run("thumbnail")
case <-stop:
return
}
}
}()
return func() {
close(stop)
}
}

View File

@@ -94,12 +94,12 @@ func applyAttr(entry map[string]any, attr slog.Attr) {
func severity(level slog.Level) string {
switch {
case level >= slog.LevelError:
return "high"
return "error"
case level >= slog.LevelWarn:
return "medium"
return "warn"
case level <= slog.LevelDebug:
return "low"
return "dev"
default:
return "info"
return "dev"
}
}

View File

@@ -41,6 +41,7 @@ func Logger(logger *slog.Logger) Middleware {
logger.Info("http request",
"source", "http",
"severity", "dev",
"code", status,
"method", r.Method,
"path", r.URL.Path,

View File

@@ -12,6 +12,9 @@ func Recoverer(logger *slog.Logger) Middleware {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("panic recovered",
"source", "panic",
"severity", "error",
"code", 5001,
"error", recovered,
"stack", string(debug.Stack()),
"request_id", RequestIDFromContext(r.Context()),

View File

@@ -3,18 +3,28 @@ package services
import (
"archive/zip"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/jpeg"
_ "image/png"
"io"
"log/slog"
"mime/multipart"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"go.etcd.io/bbolt"
_ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/helpers"
)
@@ -31,8 +41,10 @@ type UploadService struct {
}
type UploadOptions struct {
MaxDays int
MaxDownloads int
MaxDays int
MaxDownloads int
Password string
ObfuscateMetadata bool
}
type Box struct {
@@ -41,6 +53,9 @@ type Box struct {
ExpiresAt time.Time `json:"expiresAt"`
MaxDownloads int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"`
PasswordSalt string `json:"passwordSalt,omitempty"`
PasswordHash string `json:"passwordHash,omitempty"`
Obfuscate bool `json:"obfuscate"`
Files []File `json:"files"`
}
@@ -50,6 +65,8 @@ type File struct {
StoredName string `json:"storedName"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"`
UploadedAt time.Time `json:"uploadedAt"`
}
@@ -68,6 +85,36 @@ type ResultFile struct {
URL string `json:"url"`
}
type AdminStats struct {
TotalBoxes int
TotalFiles int
TotalSize int64
UploadsLast24H int
ExpiredBoxes int
ProtectedBoxes int
TotalDownloads int
TotalSizeLabel string
}
type ThumbnailJobResult struct {
Scanned int
Generated int
Failed int
}
type AdminBox struct {
ID string
CreatedAt time.Time
ExpiresAt time.Time
FileCount int
TotalSize int64
TotalSizeLabel string
DownloadCount int
MaxDownloads int
Protected bool
Expired bool
}
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
filesDir := filepath.Join(dataDir, "files")
dbDir := filepath.Join(dataDir, "db")
@@ -133,8 +180,14 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
MaxDownloads: opts.MaxDownloads,
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
Files: make([]File, 0, len(files)),
}
if strings.TrimSpace(opts.Password) != "" {
salt, hash := hashPassword(opts.Password)
box.PasswordSalt = salt
box.PasswordHash = hash
}
boxDir := filepath.Join(s.filesDir, box.ID)
if err := os.MkdirAll(boxDir, 0o755); err != nil {
@@ -152,7 +205,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
}
fileID := randomID(8)
storedName := fileID + strings.ToLower(filepath.Ext(header.Filename))
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
storedPath := filepath.Join(boxDir, storedName)
contentType := header.Header.Get("Content-Type")
if contentType == "" {
@@ -171,6 +224,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
StoredName: storedName,
Size: header.Size,
ContentType: contentType,
PreviewKind: previewKind(contentType),
UploadedAt: time.Now().UTC(),
})
}
@@ -181,6 +235,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
s.logger.Info("upload complete",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"file_count", len(box.Files),
@@ -204,6 +259,173 @@ func (s *UploadService) GetBox(id string) (Box, error) {
return box, nil
}
func (s *UploadService) ListBoxes(limit int) ([]Box, error) {
boxes := make([]Box, 0)
err := s.db.View(func(tx *bbolt.Tx) error {
cursor := tx.Bucket(boxesBucket).Cursor()
for key, value := cursor.Last(); key != nil; key, value = cursor.Prev() {
var box Box
if err := json.Unmarshal(value, &box); err != nil {
return err
}
boxes = append(boxes, box)
if limit > 0 && len(boxes) >= limit {
break
}
}
return nil
})
return boxes, err
}
func (s *UploadService) AdminStats() (AdminStats, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return AdminStats{}, err
}
var stats AdminStats
cutoff := time.Now().UTC().Add(-24 * time.Hour)
now := time.Now().UTC()
for _, box := range boxes {
stats.TotalBoxes++
stats.TotalDownloads += box.DownloadCount
if box.CreatedAt.After(cutoff) {
stats.UploadsLast24H++
}
if box.ExpiresAt.Before(now) {
stats.ExpiredBoxes++
}
if s.IsProtected(box) {
stats.ProtectedBoxes++
}
for _, file := range box.Files {
stats.TotalFiles++
stats.TotalSize += file.Size
}
}
stats.TotalSizeLabel = helpers.FormatBytes(stats.TotalSize)
return stats, nil
}
func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
boxes, err := s.ListBoxes(limit)
if err != nil {
return nil, err
}
now := time.Now().UTC()
rows := make([]AdminBox, 0, len(boxes))
for _, box := range boxes {
var size int64
for _, file := range box.Files {
size += file.Size
}
rows = append(rows, AdminBox{
ID: box.ID,
CreatedAt: box.CreatedAt,
ExpiresAt: box.ExpiresAt,
FileCount: len(box.Files),
TotalSize: size,
TotalSizeLabel: helpers.FormatBytes(size),
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: s.IsProtected(box),
Expired: box.ExpiresAt.Before(now),
})
}
return rows, nil
}
func (s *UploadService) DeleteBox(boxID string) error {
if err := s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
}); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
return err
}
s.logger.Info("box deleted", "source", "admin", "severity", "user_activity", "code", 2101, "box_id", boxID)
return nil
}
func (s *UploadService) CleanupExpired() (int, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return 0, err
}
now := time.Now().UTC()
cleaned := 0
for _, box := range boxes {
if box.ExpiresAt.After(now) {
continue
}
if err := s.DeleteBox(box.ID); err != nil {
return cleaned, err
}
cleaned++
}
if cleaned > 0 {
s.logger.Info("expired boxes cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2201, "cleaned", cleaned)
}
return cleaned, nil
}
func (s *UploadService) GenerateMissingThumbnails() (ThumbnailJobResult, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return ThumbnailJobResult{}, err
}
var result ThumbnailJobResult
for _, box := range boxes {
if time.Now().UTC().After(box.ExpiresAt) {
continue
}
changed := false
for i := range box.Files {
file := &box.Files[i]
if file.Thumbnail != "" || !needsThumbnail(*file) {
continue
}
result.Scanned++
path := s.FilePath(box, *file)
thumbnail := s.generateThumbnail(box.ID, file.ID, path, file.ContentType)
if thumbnail == "" {
result.Failed++
continue
}
file.Thumbnail = thumbnail
changed = true
result.Generated++
}
if changed {
if err := s.saveBox(box); err != nil {
return result, err
}
}
}
if result.Generated > 0 || result.Failed > 0 {
s.logger.Info("thumbnail job complete",
"source", "thumbnail",
"severity", "user_activity",
"code", 2203,
"scanned", result.Scanned,
"generated", result.Generated,
"failed", result.Failed,
)
}
return result, nil
}
func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
for _, file := range box.Files {
if file.ID == fileID {
@@ -217,6 +439,34 @@ func (s *UploadService) FilePath(box Box, file File) string {
return filepath.Join(s.filesDir, box.ID, file.StoredName)
}
func (s *UploadService) ThumbnailPath(box Box, file File) string {
if file.Thumbnail == "" {
return ""
}
return filepath.Join(s.filesDir, box.ID, file.Thumbnail)
}
func (s *UploadService) BoxMetadataPath(box Box) string {
return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json")
}
func (s *UploadService) IsProtected(box Box) bool {
return box.PasswordHash != "" && box.PasswordSalt != ""
}
func (s *UploadService) VerifyPassword(box Box, password string) bool {
if !s.IsProtected(box) {
return true
}
hash := passwordHash(box.PasswordSalt, password)
return subtle.ConstantTimeCompare([]byte(hash), []byte(box.PasswordHash)) == 1
}
func (s *UploadService) UnlockToken(box Box) string {
sum := sha256.Sum256([]byte(box.ID + ":" + box.PasswordHash))
return hex.EncodeToString(sum[:])
}
func (s *UploadService) CanDownload(box Box) error {
if time.Now().UTC().After(box.ExpiresAt) {
return fmt.Errorf("box has expired")
@@ -245,7 +495,10 @@ func (s *UploadService) RecordDownload(boxID string) error {
if err != nil {
return err
}
return bucket.Put([]byte(boxID), next)
if err := bucket.Put([]byte(boxID), next); err != nil {
return err
}
return s.writeBoxMetadata(box)
})
}
@@ -288,7 +541,10 @@ func (s *UploadService) saveBox(box Box) error {
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
if err := tx.Bucket(boxesBucket).Put([]byte(box.ID), data); err != nil {
return err
}
return s.writeBoxMetadata(box)
})
}
@@ -338,3 +594,112 @@ func randomID(byteCount int) string {
}
return base64.RawURLEncoding.EncodeToString(data)
}
func hashPassword(password string) (string, string) {
salt := randomID(18)
return salt, passwordHash(salt, password)
}
func passwordHash(salt, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}
func previewKind(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/"):
return "image"
case strings.HasPrefix(contentType, "video/"):
return "video"
case strings.HasPrefix(contentType, "audio/"):
return "audio"
default:
return "file"
}
}
func needsThumbnail(file File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video"
}
func (s *UploadService) generateThumbnail(boxID, fileID, path, contentType string) string {
thumbnailName := "@thumb@" + fileID + ".jpg"
thumbnailPath := filepath.Join(s.filesDir, boxID, thumbnailName)
var err error
switch {
case strings.HasPrefix(contentType, "image/"):
err = createImageThumbnail(path, thumbnailPath)
case strings.HasPrefix(contentType, "video/"):
err = createVideoThumbnail(path, thumbnailPath)
default:
return ""
}
if err != nil {
s.logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", fileID, "error", err.Error())
return ""
}
return thumbnailName
}
func createImageThumbnail(sourcePath, targetPath string) error {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer source.Close()
img, _, err := image.Decode(source)
if err != nil {
return err
}
thumb := resizeNearest(img, 360, 240)
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer target.Close()
return jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82})
}
func createVideoThumbnail(sourcePath, targetPath string) error {
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourcePath, "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run()
}
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return image.NewRGBA(image.Rect(0, 0, 1, 1))
}
scale := min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
if scale > 1 {
scale = 1
}
targetWidth := max(1, int(float64(width)*scale))
targetHeight := max(1, int(float64(height)*scale))
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
for x := 0; x < targetWidth; x++ {
srcX := bounds.Min.X + int(float64(x)/scale)
srcY := bounds.Min.Y + int(float64(y)/scale)
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
func (s *UploadService) writeBoxMetadata(box Box) error {
path := s.BoxMetadataPath(box)
data, err := json.MarshalIndent(box, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}

View File

@@ -18,22 +18,23 @@ type PageData struct {
BaseURL string
Title string
Description string
ImageURL string
CurrentYear int
Data any
}
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.gohtml"))
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
if err != nil {
return nil, err
}
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.gohtml"))
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html"))
if err != nil {
return nil, err
}
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.gohtml"))
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
if err != nil {
return nil, err
}

View File

@@ -240,11 +240,27 @@ h1 {
.option-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.9rem;
margin-top: 1rem;
}
.checkbox-field {
display: flex;
align-items: center;
gap: 0.55rem;
}
.checkbox-field input {
width: 1rem;
min-height: 1rem;
}
.checkbox-field span {
margin: 0;
color: var(--muted-foreground);
}
label span {
display: block;
margin-bottom: 0.4rem;
@@ -334,6 +350,16 @@ button {
background: var(--accent);
}
.button-danger {
border-color: rgba(248, 113, 113, 0.28);
background: rgba(127, 29, 29, 0.3);
color: #fecaca;
}
.button-danger:hover {
background: rgba(127, 29, 29, 0.55);
}
.button-wide {
width: 100%;
min-height: 2.75rem;
@@ -365,7 +391,8 @@ button {
height: 100%;
background: var(--primary);
transform-origin: left center;
animation: progress-pulse 1.1s ease-in-out infinite;
transform: scaleX(0);
transition: transform 180ms ease;
}
.upload-result {
@@ -396,6 +423,10 @@ button {
margin-top: 1rem;
}
.upload-queue {
margin-top: 1rem;
}
.result-item,
.download-item {
display: flex;
@@ -422,6 +453,23 @@ code {
white-space: nowrap;
}
.file-progress-side {
width: min(10rem, 32vw);
display: grid;
gap: 0.35rem;
}
.file-progress-percent {
color: var(--muted-foreground);
font-size: 0.75rem;
text-align: right;
}
.file-progress {
height: 0.35rem;
margin-top: 0;
}
.result-item small,
.download-item small,
code {
@@ -443,6 +491,10 @@ code {
place-items: center;
}
.download-view-wide {
width: min(58rem, calc(100% - 2rem));
}
.download-card {
text-align: center;
}
@@ -489,6 +541,103 @@ code {
text-decoration: none;
}
.view-toolbar {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.button.is-active {
background: var(--primary);
color: var(--primary-foreground);
}
.file-browser {
transition: opacity 160ms ease;
}
.file-card {
position: relative;
}
.thumb-link {
display: block;
overflow: hidden;
flex: 0 0 4.75rem;
width: 4.75rem;
aspect-ratio: 16 / 10;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--muted);
}
.thumb-link img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.file-main {
min-width: 0;
flex: 1;
color: var(--foreground);
text-decoration: none;
}
.file-browser.is-thumbs {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
.file-browser.is-thumbs .file-card {
display: grid;
align-content: start;
gap: 0.7rem;
}
.file-browser.is-thumbs .thumb-link {
width: 100%;
flex-basis: auto;
}
.file-browser.is-thumbs .button {
width: 100%;
}
.file-browser.images-only .file-card:not([data-kind="image"]) {
display: none;
}
.unlock-form {
margin: 1rem auto 0;
display: grid;
max-width: 22rem;
gap: 0.75rem;
}
.preview-stage {
overflow: hidden;
margin-bottom: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--background);
}
.preview-stage img,
.preview-stage video {
width: 100%;
max-height: 55vh;
display: block;
object-fit: contain;
}
.preview-stage audio {
width: calc(100% - 2rem);
margin: 1rem;
}
.site-footer {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
@@ -504,16 +653,107 @@ code {
text-decoration: none;
}
@keyframes progress-pulse {
0% {
transform: scaleX(0.12);
}
50% {
transform: scaleX(0.72);
}
100% {
transform: scaleX(1);
}
.form-error {
margin: 1rem 0 0;
color: #fecaca;
font-size: 0.9rem;
}
.admin-view {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.admin-header,
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.kicker {
margin: 0 0 0.4rem;
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.8rem;
margin-top: 1.5rem;
}
.metric-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(24, 24, 27, 0.78);
padding: 1rem;
}
.metric-card span,
.table-header p {
display: block;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.metric-card strong {
display: block;
margin-top: 0.4rem;
color: var(--foreground);
font-size: 1.35rem;
}
.admin-table-card {
margin-top: 1rem;
}
.table-header h2 {
margin: 0;
font-size: 1.05rem;
}
.table-header p {
margin: 0.3rem 0 0;
}
.admin-table-wrap {
overflow-x: auto;
margin-top: 1rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.admin-table th,
.admin-table td {
border-bottom: 1px solid var(--border);
padding: 0.75rem;
text-align: left;
vertical-align: middle;
}
.admin-table th {
color: var(--muted-foreground);
font-weight: 650;
}
.table-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.table-actions form {
margin: 0;
}
@media (max-width: 720px) {
@@ -536,10 +776,18 @@ code {
align-items: stretch;
}
.option-grid {
grid-template-columns: 1fr;
}
.result-actions {
width: 100%;
}
.file-progress-side {
width: 100%;
}
.result-actions .button {
flex: 1;
}
@@ -551,4 +799,14 @@ code {
.drop-zone {
min-height: 15rem;
}
.admin-header,
.table-header {
flex-direction: column;
align-items: stretch;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

View File

@@ -8,14 +8,38 @@
const result = document.querySelector("#upload-result");
const resultMeta = document.querySelector("#result-meta");
const resultList = document.querySelector("#result-list");
const copyAll = document.querySelector("#copy-all");
const uploadQueue = document.querySelector("#upload-queue");
const totalProgressBar = document.querySelector("#total-progress-bar");
const copyURL = document.querySelector("#copy-url");
const openBox = document.querySelector("#open-box");
const fileBrowser = document.querySelector("[data-file-browser]");
const viewButtons = document.querySelectorAll("[data-view-button]");
const previewImages = document.querySelector("[data-preview-images]");
if (fileBrowser) {
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.getAttribute("data-view-button");
fileBrowser.classList.toggle("is-list", view === "list");
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
});
});
if (previewImages) {
previewImages.addEventListener("click", () => {
fileBrowser.classList.toggle("images-only");
previewImages.classList.toggle("is-active");
});
}
}
if (!form || !dropZone || !fileInput) {
return;
}
let latestLinks = [];
let latestBoxURL = "";
let selectedFiles = [];
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
@@ -51,22 +75,12 @@
const submit = form.querySelector("button[type='submit']");
const formData = new FormData(form);
selectedFiles = Array.from(fileInput.files);
renderQueue(selectedFiles, "queued");
setLoading(true, submit);
try {
const response = await fetch(form.action, {
method: "POST",
body: formData,
headers: {
Accept: "application/json",
},
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "Upload failed");
}
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
renderResult(payload);
form.reset();
updateSelectedState([]);
@@ -77,14 +91,15 @@
}
});
if (copyAll) {
copyAll.addEventListener("click", () => {
copyText(latestLinks.join("\n"), copyAll, "Copied");
if (copyURL) {
copyURL.addEventListener("click", () => {
copyText(latestBoxURL, copyURL, "Copied");
});
}
function updateSelectedState(files) {
const count = files.length || 0;
selectedFiles = Array.from(files || []);
const count = selectedFiles.length || 0;
const title = dropZone.querySelector(".drop-title");
if (title) {
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
@@ -92,6 +107,12 @@
if (fileSummary) {
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
}
if (count > 0) {
renderQueue(selectedFiles, "queued");
} else if (uploadQueue) {
uploadQueue.hidden = true;
uploadQueue.replaceChildren();
}
}
function setLoading(isLoading, submit) {
@@ -103,6 +124,7 @@
submit.textContent = isLoading ? "Uploading..." : "Upload files";
}
updateStatus(isLoading ? "Transferring files..." : "");
setTotalProgress(isLoading ? 0 : 100);
}
function updateStatus(message) {
@@ -116,48 +138,129 @@
return;
}
latestLinks = [payload.boxUrl, payload.zipUrl].concat(payload.files.map((file) => file.url));
latestBoxURL = payload.boxUrl;
result.hidden = false;
openBox.href = payload.boxUrl;
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
resultList.replaceChildren();
payload.files.forEach((file) => {
const row = document.createElement("div");
row.className = "result-item";
const body = document.createElement("span");
const name = document.createElement("strong");
name.textContent = file.name;
const url = document.createElement("code");
url.textContent = file.url;
body.append(name, url);
const copy = document.createElement("button");
copy.className = "button button-outline";
copy.type = "button";
copy.textContent = "Copy";
copy.addEventListener("click", () => copyText(file.url, copy, "Copied"));
row.append(body, copy);
resultList.append(row);
resultList.append(createFileRow({
name: file.name,
meta: `${file.size} · ${file.url}`,
progress: 100,
status: "complete",
}));
});
}
const zip = document.createElement("div");
zip.className = "result-item";
const zipBody = document.createElement("span");
const zipName = document.createElement("strong");
zipName.textContent = "Download all as zip";
const zipUrl = document.createElement("code");
zipUrl.textContent = payload.zipUrl;
zipBody.append(zipName, zipUrl);
const zipCopy = document.createElement("button");
zipCopy.className = "button button-outline";
zipCopy.type = "button";
zipCopy.textContent = "Copy";
zipCopy.addEventListener("click", () => copyText(payload.zipUrl, zipCopy, "Copied"));
zip.append(zipBody, zipCopy);
resultList.append(zip);
function uploadWithProgress(url, formData, files) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", url);
request.setRequestHeader("Accept", "application/json");
request.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) {
updateStatus("Uploading...");
return;
}
const percent = Math.round((event.loaded / event.total) * 100);
updateStatus(`${percent}%`);
setTotalProgress(percent);
setFileProgress(files, percent);
});
request.addEventListener("load", () => {
let payload = {};
try {
payload = JSON.parse(request.responseText || "{}");
} catch (error) {
reject(new Error("Upload response could not be read"));
return;
}
if (request.status < 200 || request.status >= 300) {
reject(new Error(payload.error || "Upload failed"));
return;
}
setTotalProgress(100);
setFileProgress(files, 100);
resolve(payload);
});
request.addEventListener("error", () => reject(new Error("Network error during upload")));
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
request.send(formData);
});
}
function renderQueue(files, status) {
if (!uploadQueue) {
return;
}
uploadQueue.hidden = files.length === 0;
uploadQueue.replaceChildren();
files.forEach((file) => {
uploadQueue.append(createFileRow({
name: file.name,
meta: formatBytes(file.size),
progress: status === "queued" ? 0 : 100,
status,
}));
});
}
function createFileRow(file) {
const row = document.createElement("div");
row.className = "result-item upload-file-row";
row.dataset.fileName = file.name;
const body = document.createElement("span");
const name = document.createElement("strong");
name.textContent = file.name;
const meta = document.createElement("code");
meta.textContent = file.meta;
body.append(name, meta);
const side = document.createElement("div");
side.className = "file-progress-side";
const percent = document.createElement("span");
percent.className = "file-progress-percent";
percent.textContent = `${file.progress}%`;
const bar = document.createElement("div");
bar.className = "progress file-progress";
const fill = document.createElement("span");
fill.style.transform = `scaleX(${file.progress / 100})`;
bar.append(fill);
side.append(percent, bar);
row.append(body, side);
return row;
}
function setTotalProgress(percent) {
if (totalProgressBar) {
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
}
}
function setFileProgress(files, totalPercent) {
if (!uploadQueue) {
return;
}
const count = files.length || 1;
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
const percent = row.querySelector(".file-progress-percent");
const fill = row.querySelector(".file-progress span");
if (percent) {
percent.textContent = `${progress}%`;
}
if (fill) {
fill.style.transform = `scaleX(${progress / 100})`;
}
});
}
async function copyText(text, button, copiedLabel) {
@@ -183,4 +286,18 @@
year: "numeric",
});
}
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ["KiB", "MiB", "GiB", "TiB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
})();

View File

@@ -12,7 +12,9 @@
<meta property="og:description" content="{{.Description}}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{.BaseURL}}">
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
<meta name="twitter:card" content="summary_large_image">
{{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}}
<link rel="stylesheet" href="/static/css/app.css">
<script defer src="/static/js/app.js"></script>
</head>

View File

@@ -0,0 +1,95 @@
{{define "admin.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="admin-view" aria-labelledby="admin-title">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-title">Admin overview</h1>
</div>
<form action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</div>
<div class="metric-grid">
<article class="metric-card">
<span>Total boxes</span>
<strong>{{.Data.Stats.TotalBoxes}}</strong>
</article>
<article class="metric-card">
<span>Total files</span>
<strong>{{.Data.Stats.TotalFiles}}</strong>
</article>
<article class="metric-card">
<span>Storage used</span>
<strong>{{.Data.Stats.TotalSizeLabel}}</strong>
</article>
<article class="metric-card">
<span>Uploads 24h</span>
<strong>{{.Data.Stats.UploadsLast24H}}</strong>
</article>
<article class="metric-card">
<span>Protected</span>
<strong>{{.Data.Stats.ProtectedBoxes}}</strong>
</article>
<article class="metric-card">
<span>Expired</span>
<strong>{{.Data.Stats.ExpiredBoxes}}</strong>
</article>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Recent uploads</h2>
<p>View or remove anonymous boxes.</p>
</div>
<a class="button button-outline" href="/admin/files">View all</a>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Box</th>
<th>Files</th>
<th>Size</th>
<th>Downloads</th>
<th>Created</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Boxes}}
<tr>
<td><code>{{.ID}}</code></td>
<td>{{.FileCount}}</td>
<td>{{.TotalSizeLabel}}</td>
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.ExpiresAt}}</td>
<td>
{{if .Expired}}<span class="badge">expired</span>{{else}}<span class="badge">active</span>{{end}}
{{if .Protected}}<span class="badge">protected</span>{{end}}
</td>
<td class="table-actions">
<a class="button button-outline" href="/d/{{.ID}}">View</a>
<form action="/admin/boxes/{{.ID}}/delete" method="post">
<button class="button button-danger" type="submit">Delete</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="8">No uploads yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,23 @@
{{define "admin_login.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="download-view" aria-labelledby="admin-login-title">
<form class="card download-card" action="/admin/login" method="post">
<div class="card-content">
<div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" /><path d="M19 11V8A7 7 0 0 0 5 8v3" /><path d="M5 11h14v10H5z" /></svg>
</div>
<h1 id="admin-login-title">Admin login</h1>
<p class="download-subtitle">Use the token from <code>WARPBOX_ADMIN_TOKEN</code>.</p>
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="unlock-form">
<label>
<span>Admin token</span>
<input type="password" name="token" autocomplete="current-password" required>
</label>
<button class="button button-primary" type="submit">Sign in</button>
</div>
</div>
</form>
</section>
{{end}}

View File

@@ -1,41 +0,0 @@
{{define "download.gohtml"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="download-view" aria-labelledby="download-title">
<div class="card download-card">
<div class="card-content">
<div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
</div>
<h1 id="download-title">Download files</h1>
<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>
{{if .Data.Files}}
<div class="badge-row">
<span class="badge">Expires {{.Data.ExpiresLabel}}</span>
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
</div>
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
Download zip
</a>
<div class="download-list">
{{range .Data.Files}}
<a class="download-item" href="{{.URL}}">
<span>
<strong>{{.Name}}</strong>
<small>{{.Size}} · {{.ContentType}}</small>
</span>
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
</a>
{{end}}
</div>
{{else}}
<p class="download-subtitle">{{.Data.ExpiresLabel}}</p>
{{end}}
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,70 @@
{{define "download.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="download-view download-view-wide" aria-labelledby="download-title">
<div class="card download-card">
<div class="card-content">
<div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
</div>
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Download files{{end}}</h1>
<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>
{{if .Data.Locked}}
<form class="unlock-form" action="/d/{{.Data.Box.ID}}/unlock" method="post">
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="button button-primary" type="submit">Unlock box</button>
</form>
{{if .Data.Obfuscated}}
<p class="download-subtitle">File names, counts, and previews are hidden until the password is entered.</p>
{{end}}
{{end}}
{{if .Data.Files}}
<div class="badge-row">
<span class="badge">Expires {{.Data.ExpiresLabel}}</span>
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
</div>
{{if not .Data.Locked}}
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
Download zip
</a>
{{end}}
<div class="view-toolbar" aria-label="File view options">
<button class="button button-outline is-active" type="button" data-view-button="list">List</button>
<button class="button button-outline" type="button" data-view-button="thumbs">Thumbnails</button>
<button class="button button-outline" type="button" data-preview-images>Preview images only</button>
</div>
<div class="download-list file-browser is-list" data-file-browser>
{{range .Data.Files}}
<article class="download-item file-card" data-kind="{{.PreviewKind}}">
<a class="thumb-link" href="{{.URL}}" aria-label="Preview {{.Name}}">
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
</a>
<a class="file-main" href="{{.URL}}">
<strong>{{.Name}}</strong>
<small>{{.Size}} · {{.ContentType}}</small>
</a>
{{if not $.Data.Locked}}
<a class="button button-outline" href="{{.DownloadURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
Download
</a>
{{end}}
</article>
{{end}}
</div>
{{else if not .Data.Locked}}
<p class="download-subtitle">{{.Data.ExpiresLabel}}</p>
{{end}}
</div>
</div>
</section>
{{end}}

View File

@@ -1,4 +1,4 @@
{{define "home.gohtml"}}{{template "base" .}}{{end}}
{{define "home.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="upload-view" aria-labelledby="upload-title">
@@ -39,7 +39,11 @@
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="new-password" placeholder="Coming soon" disabled>
<input type="password" name="password" autocomplete="new-password" placeholder="Optional">
</label>
<label class="checkbox-field">
<input type="checkbox" name="obfuscate_metadata">
<span>Hide file names/count until unlocked</span>
</label>
</div>
</details>
@@ -49,8 +53,9 @@
<span>Uploading</span>
<span id="upload-status">Preparing...</span>
</div>
<div class="progress"><span></span></div>
<div class="progress"><span id="total-progress-bar"></span></div>
</div>
<div class="result-list upload-queue" id="upload-queue" hidden></div>
<div class="form-footer">
<p id="file-summary">Choose one or more files to begin.</p>
@@ -70,7 +75,7 @@
<p id="result-meta"></p>
</div>
<div class="result-actions">
<button class="button button-outline" type="button" id="copy-all">Copy all</button>
<button class="button button-outline" type="button" id="copy-url">Copy URL</button>
<a class="button button-primary" id="open-box" href="/">Open box</a>
</div>
</div>

View File

@@ -0,0 +1,36 @@
{{define "preview.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="download-view" aria-labelledby="preview-title">
<div class="card download-card">
<div class="card-content">
{{if .Data.Locked}}
<div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" /><path d="M19 11V8A7 7 0 0 0 5 8v3" /><path d="M5 11h14v10H5z" /></svg>
</div>
<h1 id="preview-title">Protected file</h1>
<p class="download-subtitle">Unlock the box before viewing this file.</p>
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
{{else}}
<div class="preview-stage">
{{if eq .Data.File.PreviewKind "image"}}
<img src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}">
{{else if eq .Data.File.PreviewKind "video"}}
<video src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata"></video>
{{else if eq .Data.File.PreviewKind "audio"}}
<audio src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata"></audio>
{{else}}
<img src="{{.Data.File.ThumbnailURL}}" alt="">
{{end}}
</div>
<h1 id="preview-title">{{.Data.File.Name}}</h1>
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
<a class="button button-primary button-wide" href="{{.Data.DownloadURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
Download file
</a>
{{end}}
</div>
</div>
</section>
{{end}}

View File

@@ -3,6 +3,9 @@ WARPBOX_ENV=development
WARPBOX_ADDR=:8080
WARPBOX_BASE_URL=http://localhost:8080
WARPBOX_DATA_DIR=./data
WARPBOX_ADMIN_TOKEN=change-me
WARPBOX_CLEANUP_EVERY=1h
WARPBOX_THUMBNAIL_EVERY=1m
WARPBOX_MAX_UPLOAD_SIZE_MB=2048
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s