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

@@ -16,11 +16,14 @@ type Config struct {
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
}
@@ -31,11 +34,14 @@ func Load() (Config, error) {
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.
}

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
@@ -29,7 +35,17 @@ type fileView struct {
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))
if !(locked && box.Obfuscate) {
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),
})
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)
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

@@ -20,9 +20,11 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
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"
)
@@ -33,6 +43,8 @@ type UploadService struct {
type UploadOptions struct {
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) => {
resultList.append(createFileRow({
name: file.name,
meta: `${file.size} · ${file.url}`,
progress: 100,
status: "complete",
}));
});
}
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";
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 url = document.createElement("code");
url.textContent = file.url;
body.append(name, url);
const meta = document.createElement("code");
meta.textContent = file.meta;
body.append(name, meta);
const copy = document.createElement("button");
copy.className = "button button-outline";
copy.type = "button";
copy.textContent = "Copy";
copy.addEventListener("click", () => copyText(file.url, copy, "Copied"));
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, copy);
resultList.append(row);
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})`;
}
});
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);
}
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