From 26619bacbcc180823a7b193c96f5bdb3041b2232 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 25 May 2026 16:52:57 +0300 Subject: [PATCH] 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. --- .env.example | 3 + README.md | 13 +- backend/cmd/warpbox/main.go | 12 +- backend/go.mod | 5 +- backend/go.sum | 2 + backend/libs/config/config.go | 50 ++- backend/libs/handlers/admin.go | 198 +++++++++ backend/libs/handlers/app.go | 9 + backend/libs/handlers/download.go | 194 ++++++++- backend/libs/handlers/pages.go | 2 +- backend/libs/handlers/upload.go | 8 +- backend/libs/httpserver/server.go | 76 +++- backend/libs/logging/logger.go | 8 +- backend/libs/middleware/logger.go | 1 + backend/libs/middleware/recoverer.go | 3 + backend/libs/services/upload.go | 375 +++++++++++++++++- backend/libs/web/renderer.go | 7 +- backend/static/css/app.css | 282 ++++++++++++- backend/static/img/file-placeholder.webp | Bin 0 -> 670 bytes backend/static/js/app.js | 223 ++++++++--- .../layouts/{base.gohtml => base.html} | 2 + backend/templates/pages/admin.html | 95 +++++ backend/templates/pages/admin_login.html | 23 ++ backend/templates/pages/download.gohtml | 41 -- backend/templates/pages/download.html | 70 ++++ .../pages/{home.gohtml => home.html} | 13 +- backend/templates/pages/preview.html | 36 ++ scripts/env/dev.env.example | 3 + 28 files changed, 1576 insertions(+), 178 deletions(-) create mode 100644 backend/libs/handlers/admin.go create mode 100644 backend/static/img/file-placeholder.webp rename backend/templates/layouts/{base.gohtml => base.html} (91%) create mode 100644 backend/templates/pages/admin.html create mode 100644 backend/templates/pages/admin_login.html delete mode 100644 backend/templates/pages/download.gohtml create mode 100644 backend/templates/pages/download.html rename backend/templates/pages/{home.gohtml => home.html} (86%) create mode 100644 backend/templates/pages/preview.html diff --git a/.env.example b/.env.example index 406cdb1..31126cd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 9dc0c29..fb34271 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/cmd/warpbox/main.go b/backend/cmd/warpbox/main.go index 0cdc443..38e8ed8 100644 --- a/backend/cmd/warpbox/main.go +++ b/backend/cmd/warpbox/main.go @@ -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") } } diff --git a/backend/go.mod b/backend/go.mod index 0c2ccfc..f8722a6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 56ca229..524af7d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/libs/config/config.go b/backend/libs/config/config.go index 807f5c4..ab34c53 100644 --- a/backend/libs/config/config.go +++ b/backend/libs/config/config.go @@ -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 == "" { diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go new file mode 100644 index 0000000..a7d2206 --- /dev/null +++ b/backend/libs/handlers/admin.go @@ -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[:]) +} diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index a9c0c26..65c8987 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -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) diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index df9c7f5..954afce 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -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) +} diff --git a/backend/libs/handlers/pages.go b/backend/libs/handlers/pages.go index e460d03..e9bd3c7 100644 --- a/backend/libs/handlers/pages.go +++ b/backend/libs/handlers/pages.go @@ -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{ diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 76fb7ad..540a311 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -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 } diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index b8504bc..bd26f41 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -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) + } +} diff --git a/backend/libs/logging/logger.go b/backend/libs/logging/logger.go index 0da1e7c..5f02a5a 100644 --- a/backend/libs/logging/logger.go +++ b/backend/libs/logging/logger.go @@ -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" } } diff --git a/backend/libs/middleware/logger.go b/backend/libs/middleware/logger.go index 63af3fc..e8dfdb3 100644 --- a/backend/libs/middleware/logger.go +++ b/backend/libs/middleware/logger.go @@ -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, diff --git a/backend/libs/middleware/recoverer.go b/backend/libs/middleware/recoverer.go index e92c8f5..b04e4c9 100644 --- a/backend/libs/middleware/recoverer.go +++ b/backend/libs/middleware/recoverer.go @@ -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()), diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 1d603b7..792d12f 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -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) +} diff --git a/backend/libs/web/renderer.go b/backend/libs/web/renderer.go index c8b0f7d..8e42baa 100644 --- a/backend/libs/web/renderer.go +++ b/backend/libs/web/renderer.go @@ -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 } diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 4473adb..00cb6c4 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -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)); + } } diff --git a/backend/static/img/file-placeholder.webp b/backend/static/img/file-placeholder.webp new file mode 100644 index 0000000000000000000000000000000000000000..505b2eaa289760f7b70aca386f2fd28684f54deb GIT binary patch literal 670 zcmWIYbaR`=#J~{l>J$(bV4=_jWE(g!%w^PSV9H>$o9MC7$9{>jl2XJB=?_AjnRyS( z6QvIEZz(A2TiKN5WNLkrFD#g1wvR*e<23updqi_APe+kc^1H3aL({!f2D`^2oK z^O;}Npjdp(rayWCS>lzYJT{$OX=TTeTz2t^lE+8*fUp z_c44k-e%vTH19kV_bZ%#FAUb^*S#}v?#4fgBHwBt_)DcGU&E(AySg}nUz8=i4oA2{ zwRuCP+gz#Ed+vReV$9abKb$n*K=F%Do=#`}PMxvq>aAMN%}|{1zIQ&`EHoqbnbk;T zcCU?HeqpvP1W&zodd2lAix%Y`OaJEl>qFM=viVLq-(Yyf4aFa?Uq!yPYq?1~oWsEI z?<3c=NjDnYUI;J%fmEFXZ{OSlNe^EysoM2YZpD(+>&uqEdbjnp8>7|R^Ly9u%}smE zRBZm-j6*QdCF7&k^% { + 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]}`; + } })(); diff --git a/backend/templates/layouts/base.gohtml b/backend/templates/layouts/base.html similarity index 91% rename from backend/templates/layouts/base.gohtml rename to backend/templates/layouts/base.html index a3f2a7e..06a564a 100644 --- a/backend/templates/layouts/base.gohtml +++ b/backend/templates/layouts/base.html @@ -12,7 +12,9 @@ + {{if .ImageURL}}{{end}} + {{if .ImageURL}}{{end}} diff --git a/backend/templates/pages/admin.html b/backend/templates/pages/admin.html new file mode 100644 index 0000000..61892f5 --- /dev/null +++ b/backend/templates/pages/admin.html @@ -0,0 +1,95 @@ +{{define "admin.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+
+
+

Operator console

+

Admin overview

+
+
+ +
+
+ +
+
+ Total boxes + {{.Data.Stats.TotalBoxes}} +
+
+ Total files + {{.Data.Stats.TotalFiles}} +
+
+ Storage used + {{.Data.Stats.TotalSizeLabel}} +
+
+ Uploads 24h + {{.Data.Stats.UploadsLast24H}} +
+
+ Protected + {{.Data.Stats.ProtectedBoxes}} +
+
+ Expired + {{.Data.Stats.ExpiredBoxes}} +
+
+ +
+
+
+
+

Recent uploads

+

View or remove anonymous boxes.

+
+ View all +
+ +
+ + + + + + + + + + + + + + + {{range .Data.Boxes}} + + + + + + + + + + + {{else}} + + {{end}} + +
BoxFilesSizeDownloadsCreatedExpiresStatusActions
{{.ID}}{{.FileCount}}{{.TotalSizeLabel}}{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}{{.CreatedAt}}{{.ExpiresAt}} + {{if .Expired}}expired{{else}}active{{end}} + {{if .Protected}}protected{{end}} + + View +
+ +
+
No uploads yet.
+
+
+
+
+{{end}} diff --git a/backend/templates/pages/admin_login.html b/backend/templates/pages/admin_login.html new file mode 100644 index 0000000..7f3d107 --- /dev/null +++ b/backend/templates/pages/admin_login.html @@ -0,0 +1,23 @@ +{{define "admin_login.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+
+
+ +

Admin login

+

Use the token from WARPBOX_ADMIN_TOKEN.

+ {{if .Data.Error}}

{{.Data.Error}}

{{end}} +
+ + +
+
+
+
+{{end}} diff --git a/backend/templates/pages/download.gohtml b/backend/templates/pages/download.gohtml deleted file mode 100644 index 08d68a2..0000000 --- a/backend/templates/pages/download.gohtml +++ /dev/null @@ -1,41 +0,0 @@ -{{define "download.gohtml"}}{{template "base" .}}{{end}} - -{{define "content"}} -
-
-
- -

Download files

-

Bucket id: {{.Data.Box.ID}}

- - {{if .Data.Files}} -
- Expires {{.Data.ExpiresLabel}} - {{if .Data.MaxDownloads}}{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads{{end}} -
- - - - Download zip - - -
- {{range .Data.Files}} - - - {{.Name}} - {{.Size}} · {{.ContentType}} - - - - {{end}} -
- {{else}} -

{{.Data.ExpiresLabel}}

- {{end}} -
-
-
-{{end}} diff --git a/backend/templates/pages/download.html b/backend/templates/pages/download.html new file mode 100644 index 0000000..90537a0 --- /dev/null +++ b/backend/templates/pages/download.html @@ -0,0 +1,70 @@ +{{define "download.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+
+
+ +

{{if .Data.Locked}}Protected box{{else}}Download files{{end}}

+

Bucket id: {{.Data.Box.ID}}

+ + {{if .Data.Locked}} +
+ + +
+ {{if .Data.Obfuscated}} +

File names, counts, and previews are hidden until the password is entered.

+ {{end}} + {{end}} + + {{if .Data.Files}} +
+ Expires {{.Data.ExpiresLabel}} + {{if .Data.MaxDownloads}}{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads{{end}} +
+ + {{if not .Data.Locked}} + + + Download zip + + {{end}} + +
+ + + +
+ +
+ {{range .Data.Files}} + + {{end}} +
+ {{else if not .Data.Locked}} +

{{.Data.ExpiresLabel}}

+ {{end}} +
+
+
+{{end}} diff --git a/backend/templates/pages/home.gohtml b/backend/templates/pages/home.html similarity index 86% rename from backend/templates/pages/home.gohtml rename to backend/templates/pages/home.html index 582e5ce..ff95ad0 100644 --- a/backend/templates/pages/home.gohtml +++ b/backend/templates/pages/home.html @@ -1,4 +1,4 @@ -{{define "home.gohtml"}}{{template "base" .}}{{end}} +{{define "home.html"}}{{template "base" .}}{{end}} {{define "content"}}
@@ -39,7 +39,11 @@ + @@ -49,8 +53,9 @@ Uploading Preparing... -
+
+
- + Open box
diff --git a/backend/templates/pages/preview.html b/backend/templates/pages/preview.html new file mode 100644 index 0000000..d229958 --- /dev/null +++ b/backend/templates/pages/preview.html @@ -0,0 +1,36 @@ +{{define "preview.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+
+
+ {{if .Data.Locked}} + +

Protected file

+

Unlock the box before viewing this file.

+ Unlock box + {{else}} +
+ {{if eq .Data.File.PreviewKind "image"}} + {{.Data.File.Name}} + {{else if eq .Data.File.PreviewKind "video"}} + + {{else if eq .Data.File.PreviewKind "audio"}} + + {{else}} + + {{end}} +
+

{{.Data.File.Name}}

+

{{.Data.File.Size}} · {{.Data.File.ContentType}}

+ + + Download file + + {{end}} +
+
+
+{{end}} diff --git a/scripts/env/dev.env.example b/scripts/env/dev.env.example index 406cdb1..31126cd 100644 --- a/scripts/env/dev.env.example +++ b/scripts/env/dev.env.example @@ -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