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:
@@ -3,6 +3,9 @@ WARPBOX_ENV=development
|
|||||||
WARPBOX_ADDR=:8080
|
WARPBOX_ADDR=:8080
|
||||||
WARPBOX_BASE_URL=http://localhost:8080
|
WARPBOX_BASE_URL=http://localhost:8080
|
||||||
WARPBOX_DATA_DIR=./data
|
WARPBOX_DATA_DIR=./data
|
||||||
|
WARPBOX_ADMIN_TOKEN=change-me
|
||||||
|
WARPBOX_CLEANUP_EVERY=1h
|
||||||
|
WARPBOX_THUMBNAIL_EVERY=1m
|
||||||
WARPBOX_MAX_UPLOAD_SIZE_MB=2048
|
WARPBOX_MAX_UPLOAD_SIZE_MB=2048
|
||||||
WARPBOX_READ_TIMEOUT=15s
|
WARPBOX_READ_TIMEOUT=15s
|
||||||
WARPBOX_WRITE_TIMEOUT=60s
|
WARPBOX_WRITE_TIMEOUT=60s
|
||||||
|
|||||||
13
README.md
13
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.
|
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 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:
|
For one-off Go commands, run them from the backend module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -39,11 +41,20 @@ go run ./cmd/warpbox
|
|||||||
- `scripts/env/dev.env.example` - tracked development environment template.
|
- `scripts/env/dev.env.example` - tracked development environment template.
|
||||||
- `scripts/env/dev.env` - local development environment, ignored by git.
|
- `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
|
## Runtime Data
|
||||||
|
|
||||||
Warpbox keeps local runtime data under the configured data directory:
|
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/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
|
||||||
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ func main() {
|
|||||||
|
|
||||||
server, err := httpserver.New(cfg, logger)
|
server, err := httpserver.New(cfg, logger)
|
||||||
if err != nil {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
errs := make(chan error, 1)
|
errs := make(chan error, 1)
|
||||||
go func() {
|
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()
|
errs <- server.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -46,18 +46,18 @@ func main() {
|
|||||||
select {
|
select {
|
||||||
case err := <-errs:
|
case err := <-errs:
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
case sig := <-shutdown:
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
logger.Info("server stopped", "source", "startup")
|
logger.Info("server stopped", "source", "startup", "severity", "dev")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ module warpbox.dev/backend
|
|||||||
|
|
||||||
go 1.26
|
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
|
require golang.org/x/sys v0.29.0 // indirect
|
||||||
|
|||||||
@@ -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=
|
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 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
|||||||
@@ -11,32 +11,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppName string
|
AppName string
|
||||||
Environment string
|
Environment string
|
||||||
Addr string
|
Addr string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
DataDir string
|
DataDir string
|
||||||
StaticDir string
|
AdminToken string
|
||||||
TemplateDir string
|
StaticDir string
|
||||||
ReadTimeout time.Duration
|
TemplateDir string
|
||||||
WriteTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
IdleTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
MaxUploadSize int64
|
IdleTimeout time.Duration
|
||||||
|
CleanupEvery time.Duration
|
||||||
|
ThumbnailEvery time.Duration
|
||||||
|
MaxUploadSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||||
Environment: envString("WARPBOX_ENV", "development"),
|
Environment: envString("WARPBOX_ENV", "development"),
|
||||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||||
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
|
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
|
||||||
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
|
||||||
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
||||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
||||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
||||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
||||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
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 == "" {
|
if cfg.BaseURL == "" {
|
||||||
|
|||||||
198
backend/libs/handlers/admin.go
Normal file
198
backend/libs/handlers/admin.go
Normal 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[:])
|
||||||
|
}
|
||||||
@@ -27,9 +27,18 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
|||||||
|
|
||||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /", a.Home)
|
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("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}/zip", a.DownloadZip)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
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 /healthz", a.Health)
|
||||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||||
mux.HandleFunc("POST /api/v1/upload", a.Upload)
|
mux.HandleFunc("POST /api/v1/upload", a.Upload)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/helpers"
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +18,9 @@ type downloadPageData struct {
|
|||||||
Box boxView
|
Box boxView
|
||||||
Files []fileView
|
Files []fileView
|
||||||
ZipURL string
|
ZipURL string
|
||||||
|
Locked bool
|
||||||
|
Obfuscated bool
|
||||||
|
CanPreview bool
|
||||||
DownloadCount int
|
DownloadCount int
|
||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
ExpiresLabel string
|
ExpiresLabel string
|
||||||
@@ -25,11 +31,21 @@ type boxView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fileView struct {
|
type fileView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Size string
|
Size string
|
||||||
ContentType string
|
ContentType string
|
||||||
URL 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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
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",
|
Title: "Download unavailable",
|
||||||
Description: "This Warpbox link is no longer available.",
|
Description: "This Warpbox link is no longer available.",
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
@@ -49,25 +65,24 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||||
|
|
||||||
files := make([]fileView, 0, len(box.Files))
|
files := make([]fileView, 0, len(box.Files))
|
||||||
for _, file := range box.Files {
|
if !(locked && box.Obfuscate) {
|
||||||
files = append(files, fileView{
|
for _, file := range box.Files {
|
||||||
ID: file.ID,
|
files = append(files, a.fileView(box, file))
|
||||||
Name: file.Name,
|
}
|
||||||
Size: helpers.FormatBytes(file.Size),
|
|
||||||
ContentType: file.ContentType,
|
|
||||||
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "download.gohtml", web.PageData{
|
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{
|
||||||
Title: "Download files",
|
Title: "Download files",
|
||||||
Description: "Download files shared through Warpbox.",
|
Description: "Download files shared through Warpbox.",
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
Files: files,
|
Files: files,
|
||||||
ZipURL: fmt.Sprintf("/d/%s/zip", box.ID),
|
ZipURL: fmt.Sprintf("/d/%s/zip", box.ID),
|
||||||
|
Locked: locked,
|
||||||
|
Obfuscated: box.Obfuscate,
|
||||||
DownloadCount: box.DownloadCount,
|
DownloadCount: box.DownloadCount,
|
||||||
MaxDownloads: box.MaxDownloads,
|
MaxDownloads: box.MaxDownloads,
|
||||||
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
|
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) {
|
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"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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 {
|
if err := a.uploadService.CanDownload(box); err != nil {
|
||||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||||
return
|
return services.Box{}, services.File{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
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)
|
path := a.uploadService.FilePath(box, file)
|
||||||
source, err := os.Open(path)
|
source, err := os.Open(path)
|
||||||
if err != nil {
|
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-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)
|
http.ServeContent(w, r, file.Name, stat.ModTime(), source)
|
||||||
|
|
||||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
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))
|
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||||
return
|
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-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".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))
|
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||||
|
|
||||||
if err := a.uploadService.WriteZip(w, box); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type homeData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
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",
|
Title: "Upload your files",
|
||||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
||||||
Data: homeData{
|
Data: homeData{
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
files := r.MultipartForm.File["file"]
|
files := r.MultipartForm.File["file"]
|
||||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
||||||
MaxDays: parseInt(r.FormValue("max_days")),
|
MaxDays: parseInt(r.FormValue("max_days")),
|
||||||
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||||
|
Password: r.FormValue("password"),
|
||||||
|
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
||||||
})
|
})
|
||||||
if err != nil {
|
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())
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httpserver
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
"warpbox.dev/backend/libs/handlers"
|
"warpbox.dev/backend/libs/handlers"
|
||||||
@@ -21,6 +22,8 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
stopCleanup := startCleanup(uploadService, cfg.CleanupEvery, logger)
|
||||||
|
stopThumbnails := startThumbnails(uploadService, cfg.ThumbnailEvery, logger)
|
||||||
app := handlers.NewApp(cfg, logger, renderer, uploadService)
|
app := handlers.NewApp(cfg, logger, renderer, uploadService)
|
||||||
|
|
||||||
router := http.NewServeMux()
|
router := http.NewServeMux()
|
||||||
@@ -43,10 +46,81 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
IdleTimeout: cfg.IdleTimeout,
|
IdleTimeout: cfg.IdleTimeout,
|
||||||
}
|
}
|
||||||
server.RegisterOnShutdown(func() {
|
server.RegisterOnShutdown(func() {
|
||||||
|
stopCleanup()
|
||||||
|
stopThumbnails()
|
||||||
if err := uploadService.Close(); err != nil {
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,12 +94,12 @@ func applyAttr(entry map[string]any, attr slog.Attr) {
|
|||||||
func severity(level slog.Level) string {
|
func severity(level slog.Level) string {
|
||||||
switch {
|
switch {
|
||||||
case level >= slog.LevelError:
|
case level >= slog.LevelError:
|
||||||
return "high"
|
return "error"
|
||||||
case level >= slog.LevelWarn:
|
case level >= slog.LevelWarn:
|
||||||
return "medium"
|
return "warn"
|
||||||
case level <= slog.LevelDebug:
|
case level <= slog.LevelDebug:
|
||||||
return "low"
|
return "dev"
|
||||||
default:
|
default:
|
||||||
return "info"
|
return "dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func Logger(logger *slog.Logger) Middleware {
|
|||||||
|
|
||||||
logger.Info("http request",
|
logger.Info("http request",
|
||||||
"source", "http",
|
"source", "http",
|
||||||
|
"severity", "dev",
|
||||||
"code", status,
|
"code", status,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ func Recoverer(logger *slog.Logger) Middleware {
|
|||||||
defer func() {
|
defer func() {
|
||||||
if recovered := recover(); recovered != nil {
|
if recovered := recover(); recovered != nil {
|
||||||
logger.Error("panic recovered",
|
logger.Error("panic recovered",
|
||||||
|
"source", "panic",
|
||||||
|
"severity", "error",
|
||||||
|
"code", 5001,
|
||||||
"error", recovered,
|
"error", recovered,
|
||||||
"stack", string(debug.Stack()),
|
"stack", string(debug.Stack()),
|
||||||
"request_id", RequestIDFromContext(r.Context()),
|
"request_id", RequestIDFromContext(r.Context()),
|
||||||
|
|||||||
@@ -3,18 +3,28 @@ package services
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/helpers"
|
"warpbox.dev/backend/libs/helpers"
|
||||||
)
|
)
|
||||||
@@ -31,8 +41,10 @@ type UploadService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UploadOptions struct {
|
type UploadOptions struct {
|
||||||
MaxDays int
|
MaxDays int
|
||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
|
Password string
|
||||||
|
ObfuscateMetadata bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Box struct {
|
type Box struct {
|
||||||
@@ -41,6 +53,9 @@ type Box struct {
|
|||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
MaxDownloads int `json:"maxDownloads"`
|
MaxDownloads int `json:"maxDownloads"`
|
||||||
DownloadCount int `json:"downloadCount"`
|
DownloadCount int `json:"downloadCount"`
|
||||||
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||||
|
PasswordHash string `json:"passwordHash,omitempty"`
|
||||||
|
Obfuscate bool `json:"obfuscate"`
|
||||||
Files []File `json:"files"`
|
Files []File `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +65,8 @@ type File struct {
|
|||||||
StoredName string `json:"storedName"`
|
StoredName string `json:"storedName"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
PreviewKind string `json:"previewKind"`
|
||||||
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
UploadedAt time.Time `json:"uploadedAt"`
|
UploadedAt time.Time `json:"uploadedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +85,36 @@ type ResultFile struct {
|
|||||||
URL string `json:"url"`
|
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) {
|
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
||||||
filesDir := filepath.Join(dataDir, "files")
|
filesDir := filepath.Join(dataDir, "files")
|
||||||
dbDir := filepath.Join(dataDir, "db")
|
dbDir := filepath.Join(dataDir, "db")
|
||||||
@@ -133,8 +180,14 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
||||||
MaxDownloads: opts.MaxDownloads,
|
MaxDownloads: opts.MaxDownloads,
|
||||||
|
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
||||||
Files: make([]File, 0, len(files)),
|
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)
|
boxDir := filepath.Join(s.filesDir, box.ID)
|
||||||
if err := os.MkdirAll(boxDir, 0o755); err != nil {
|
if err := os.MkdirAll(boxDir, 0o755); err != nil {
|
||||||
@@ -152,7 +205,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileID := randomID(8)
|
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)
|
storedPath := filepath.Join(boxDir, storedName)
|
||||||
contentType := header.Header.Get("Content-Type")
|
contentType := header.Header.Get("Content-Type")
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
@@ -171,6 +224,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
StoredName: storedName,
|
StoredName: storedName,
|
||||||
Size: header.Size,
|
Size: header.Size,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
PreviewKind: previewKind(contentType),
|
||||||
UploadedAt: time.Now().UTC(),
|
UploadedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -181,6 +235,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
|
|
||||||
s.logger.Info("upload complete",
|
s.logger.Info("upload complete",
|
||||||
"source", "user-upload",
|
"source", "user-upload",
|
||||||
|
"severity", "user_activity",
|
||||||
"code", 2001,
|
"code", 2001,
|
||||||
"box_id", box.ID,
|
"box_id", box.ID,
|
||||||
"file_count", len(box.Files),
|
"file_count", len(box.Files),
|
||||||
@@ -204,6 +259,173 @@ func (s *UploadService) GetBox(id string) (Box, error) {
|
|||||||
return box, nil
|
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) {
|
func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
|
||||||
for _, file := range box.Files {
|
for _, file := range box.Files {
|
||||||
if file.ID == fileID {
|
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)
|
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 {
|
func (s *UploadService) CanDownload(box Box) error {
|
||||||
if time.Now().UTC().After(box.ExpiresAt) {
|
if time.Now().UTC().After(box.ExpiresAt) {
|
||||||
return fmt.Errorf("box has expired")
|
return fmt.Errorf("box has expired")
|
||||||
@@ -245,7 +495,10 @@ func (s *UploadService) RecordDownload(boxID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,22 +18,23 @@ type PageData struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
|
ImageURL string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.gohtml"))
|
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.gohtml"))
|
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,11 +240,27 @@ h1 {
|
|||||||
|
|
||||||
.option-grid {
|
.option-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
margin-top: 1rem;
|
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 {
|
label span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
@@ -334,6 +350,16 @@ button {
|
|||||||
background: var(--accent);
|
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 {
|
.button-wide {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
@@ -365,7 +391,8 @@ button {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
animation: progress-pulse 1.1s ease-in-out infinite;
|
transform: scaleX(0);
|
||||||
|
transition: transform 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-result {
|
.upload-result {
|
||||||
@@ -396,6 +423,10 @@ button {
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-queue {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.result-item,
|
.result-item,
|
||||||
.download-item {
|
.download-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -422,6 +453,23 @@ code {
|
|||||||
white-space: nowrap;
|
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,
|
.result-item small,
|
||||||
.download-item small,
|
.download-item small,
|
||||||
code {
|
code {
|
||||||
@@ -443,6 +491,10 @@ code {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-view-wide {
|
||||||
|
width: min(58rem, calc(100% - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
.download-card {
|
.download-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -489,6 +541,103 @@ code {
|
|||||||
text-decoration: none;
|
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 {
|
.site-footer {
|
||||||
width: min(72rem, calc(100% - 2rem));
|
width: min(72rem, calc(100% - 2rem));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -504,16 +653,107 @@ code {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes progress-pulse {
|
.form-error {
|
||||||
0% {
|
margin: 1rem 0 0;
|
||||||
transform: scaleX(0.12);
|
color: #fecaca;
|
||||||
}
|
font-size: 0.9rem;
|
||||||
50% {
|
}
|
||||||
transform: scaleX(0.72);
|
|
||||||
}
|
.admin-view {
|
||||||
100% {
|
width: min(72rem, calc(100% - 2rem));
|
||||||
transform: scaleX(1);
|
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) {
|
@media (max-width: 720px) {
|
||||||
@@ -536,10 +776,18 @@ code {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.result-actions {
|
.result-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-progress-side {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.result-actions .button {
|
.result-actions .button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -551,4 +799,14 @@ code {
|
|||||||
.drop-zone {
|
.drop-zone {
|
||||||
min-height: 15rem;
|
min-height: 15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-header,
|
||||||
|
.table-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
backend/static/img/file-placeholder.webp
Normal file
BIN
backend/static/img/file-placeholder.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 670 B |
@@ -8,14 +8,38 @@
|
|||||||
const result = document.querySelector("#upload-result");
|
const result = document.querySelector("#upload-result");
|
||||||
const resultMeta = document.querySelector("#result-meta");
|
const resultMeta = document.querySelector("#result-meta");
|
||||||
const resultList = document.querySelector("#result-list");
|
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 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) {
|
if (!form || !dropZone || !fileInput) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let latestLinks = [];
|
let latestBoxURL = "";
|
||||||
|
let selectedFiles = [];
|
||||||
|
|
||||||
["dragenter", "dragover"].forEach((eventName) => {
|
["dragenter", "dragover"].forEach((eventName) => {
|
||||||
dropZone.addEventListener(eventName, (event) => {
|
dropZone.addEventListener(eventName, (event) => {
|
||||||
@@ -51,22 +75,12 @@
|
|||||||
|
|
||||||
const submit = form.querySelector("button[type='submit']");
|
const submit = form.querySelector("button[type='submit']");
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
selectedFiles = Array.from(fileInput.files);
|
||||||
|
renderQueue(selectedFiles, "queued");
|
||||||
setLoading(true, submit);
|
setLoading(true, submit);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(form.action, {
|
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload.error || "Upload failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(payload);
|
renderResult(payload);
|
||||||
form.reset();
|
form.reset();
|
||||||
updateSelectedState([]);
|
updateSelectedState([]);
|
||||||
@@ -77,14 +91,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (copyAll) {
|
if (copyURL) {
|
||||||
copyAll.addEventListener("click", () => {
|
copyURL.addEventListener("click", () => {
|
||||||
copyText(latestLinks.join("\n"), copyAll, "Copied");
|
copyText(latestBoxURL, copyURL, "Copied");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectedState(files) {
|
function updateSelectedState(files) {
|
||||||
const count = files.length || 0;
|
selectedFiles = Array.from(files || []);
|
||||||
|
const count = selectedFiles.length || 0;
|
||||||
const title = dropZone.querySelector(".drop-title");
|
const title = dropZone.querySelector(".drop-title");
|
||||||
if (title) {
|
if (title) {
|
||||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||||
@@ -92,6 +107,12 @@
|
|||||||
if (fileSummary) {
|
if (fileSummary) {
|
||||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
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) {
|
function setLoading(isLoading, submit) {
|
||||||
@@ -103,6 +124,7 @@
|
|||||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||||
}
|
}
|
||||||
updateStatus(isLoading ? "Transferring files..." : "");
|
updateStatus(isLoading ? "Transferring files..." : "");
|
||||||
|
setTotalProgress(isLoading ? 0 : 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatus(message) {
|
function updateStatus(message) {
|
||||||
@@ -116,48 +138,129 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
latestLinks = [payload.boxUrl, payload.zipUrl].concat(payload.files.map((file) => file.url));
|
latestBoxURL = payload.boxUrl;
|
||||||
result.hidden = false;
|
result.hidden = false;
|
||||||
openBox.href = payload.boxUrl;
|
openBox.href = payload.boxUrl;
|
||||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
|
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
|
||||||
|
|
||||||
resultList.replaceChildren();
|
resultList.replaceChildren();
|
||||||
payload.files.forEach((file) => {
|
payload.files.forEach((file) => {
|
||||||
const row = document.createElement("div");
|
resultList.append(createFileRow({
|
||||||
row.className = "result-item";
|
name: file.name,
|
||||||
|
meta: `${file.size} · ${file.url}`,
|
||||||
const body = document.createElement("span");
|
progress: 100,
|
||||||
const name = document.createElement("strong");
|
status: "complete",
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const zip = document.createElement("div");
|
function uploadWithProgress(url, formData, files) {
|
||||||
zip.className = "result-item";
|
return new Promise((resolve, reject) => {
|
||||||
const zipBody = document.createElement("span");
|
const request = new XMLHttpRequest();
|
||||||
const zipName = document.createElement("strong");
|
request.open("POST", url);
|
||||||
zipName.textContent = "Download all as zip";
|
request.setRequestHeader("Accept", "application/json");
|
||||||
const zipUrl = document.createElement("code");
|
|
||||||
zipUrl.textContent = payload.zipUrl;
|
request.upload.addEventListener("progress", (event) => {
|
||||||
zipBody.append(zipName, zipUrl);
|
if (!event.lengthComputable) {
|
||||||
const zipCopy = document.createElement("button");
|
updateStatus("Uploading...");
|
||||||
zipCopy.className = "button button-outline";
|
return;
|
||||||
zipCopy.type = "button";
|
}
|
||||||
zipCopy.textContent = "Copy";
|
const percent = Math.round((event.loaded / event.total) * 100);
|
||||||
zipCopy.addEventListener("click", () => copyText(payload.zipUrl, zipCopy, "Copied"));
|
updateStatus(`${percent}%`);
|
||||||
zip.append(zipBody, zipCopy);
|
setTotalProgress(percent);
|
||||||
resultList.append(zip);
|
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) {
|
async function copyText(text, button, copiedLabel) {
|
||||||
@@ -183,4 +286,18 @@
|
|||||||
year: "numeric",
|
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]}`;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
<meta property="og:description" content="{{.Description}}">
|
<meta property="og:description" content="{{.Description}}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="{{.BaseURL}}">
|
<meta property="og:url" content="{{.BaseURL}}">
|
||||||
|
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<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">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
<script defer src="/static/js/app.js"></script>
|
<script defer src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
95
backend/templates/pages/admin.html
Normal file
95
backend/templates/pages/admin.html
Normal 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}}
|
||||||
23
backend/templates/pages/admin_login.html
Normal file
23
backend/templates/pages/admin_login.html
Normal 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}}
|
||||||
@@ -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}}
|
|
||||||
70
backend/templates/pages/download.html
Normal file
70
backend/templates/pages/download.html
Normal 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}}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "home.gohtml"}}{{template "base" .}}{{end}}
|
{{define "home.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="upload-view" aria-labelledby="upload-title">
|
<section class="upload-view" aria-labelledby="upload-title">
|
||||||
@@ -39,7 +39,11 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Password</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -49,8 +53,9 @@
|
|||||||
<span>Uploading</span>
|
<span>Uploading</span>
|
||||||
<span id="upload-status">Preparing...</span>
|
<span id="upload-status">Preparing...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress"><span></span></div>
|
<div class="progress"><span id="total-progress-bar"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="result-list upload-queue" id="upload-queue" hidden></div>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="file-summary">Choose one or more files to begin.</p>
|
<p id="file-summary">Choose one or more files to begin.</p>
|
||||||
@@ -70,7 +75,7 @@
|
|||||||
<p id="result-meta"></p>
|
<p id="result-meta"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-actions">
|
<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>
|
<a class="button button-primary" id="open-box" href="/">Open box</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
36
backend/templates/pages/preview.html
Normal file
36
backend/templates/pages/preview.html
Normal 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}}
|
||||||
3
scripts/env/dev.env.example
vendored
3
scripts/env/dev.env.example
vendored
@@ -3,6 +3,9 @@ WARPBOX_ENV=development
|
|||||||
WARPBOX_ADDR=:8080
|
WARPBOX_ADDR=:8080
|
||||||
WARPBOX_BASE_URL=http://localhost:8080
|
WARPBOX_BASE_URL=http://localhost:8080
|
||||||
WARPBOX_DATA_DIR=./data
|
WARPBOX_DATA_DIR=./data
|
||||||
|
WARPBOX_ADMIN_TOKEN=change-me
|
||||||
|
WARPBOX_CLEANUP_EVERY=1h
|
||||||
|
WARPBOX_THUMBNAIL_EVERY=1m
|
||||||
WARPBOX_MAX_UPLOAD_SIZE_MB=2048
|
WARPBOX_MAX_UPLOAD_SIZE_MB=2048
|
||||||
WARPBOX_READ_TIMEOUT=15s
|
WARPBOX_READ_TIMEOUT=15s
|
||||||
WARPBOX_WRITE_TIMEOUT=60s
|
WARPBOX_WRITE_TIMEOUT=60s
|
||||||
|
|||||||
Reference in New Issue
Block a user