Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ab5021667 | |||
| c9f865cd85 |
@@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
|
|||||||
WARPBOX_SHORT_WINDOW_SECONDS=60
|
WARPBOX_SHORT_WINDOW_SECONDS=60
|
||||||
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
|
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
|
||||||
WARPBOX_USER_STORAGE_BACKEND=local
|
WARPBOX_USER_STORAGE_BACKEND=local
|
||||||
WARPBOX_READ_TIMEOUT=15s
|
WARPBOX_READ_HEADER_TIMEOUT=15s
|
||||||
WARPBOX_WRITE_TIMEOUT=60s
|
WARPBOX_READ_TIMEOUT=0s
|
||||||
|
WARPBOX_WRITE_TIMEOUT=0s
|
||||||
WARPBOX_IDLE_TIMEOUT=120s
|
WARPBOX_IDLE_TIMEOUT=120s
|
||||||
WARPBOX_TRUSTED_PROXIES=
|
WARPBOX_TRUSTED_PROXIES=
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ Upload policy defaults are also configured in megabytes and can later be changed
|
|||||||
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.
|
||||||
|
|
||||||
|
Large uploads are expected to take minutes on normal home/server connections. Keep
|
||||||
|
`WARPBOX_READ_TIMEOUT=0s` and `WARPBOX_WRITE_TIMEOUT=0s` so Go does not close the connection
|
||||||
|
mid-upload; `WARPBOX_READ_HEADER_TIMEOUT=15s` still protects header reads from slowloris-style
|
||||||
|
connections.
|
||||||
|
|
||||||
Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with
|
Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with
|
||||||
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
||||||
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
||||||
@@ -106,6 +111,9 @@ WARPBOX_DATA_DIR=/var/lib/warpbox
|
|||||||
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
|
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
|
||||||
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
|
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
|
||||||
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1
|
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1
|
||||||
|
WARPBOX_READ_HEADER_TIMEOUT=15s
|
||||||
|
WARPBOX_READ_TIMEOUT=0s
|
||||||
|
WARPBOX_WRITE_TIMEOUT=0s
|
||||||
```
|
```
|
||||||
|
|
||||||
Example `/etc/systemd/system/warpbox.service`:
|
Example `/etc/systemd/system/warpbox.service`:
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ network edge, or set it to a value that does not include public clients. Direct
|
|||||||
public exposure is not recommended; use a reverse proxy for TLS and request
|
public exposure is not recommended; use a reverse proxy for TLS and request
|
||||||
normalization.
|
normalization.
|
||||||
|
|
||||||
|
## Large Uploads
|
||||||
|
|
||||||
|
Multi-GB uploads must not use whole-body read/write deadlines. Keep these
|
||||||
|
Warpbox values for production unless you intentionally want a hard wall-clock
|
||||||
|
upload limit:
|
||||||
|
|
||||||
|
```env
|
||||||
|
WARPBOX_READ_HEADER_TIMEOUT=15s
|
||||||
|
WARPBOX_READ_TIMEOUT=0s
|
||||||
|
WARPBOX_WRITE_TIMEOUT=0s
|
||||||
|
```
|
||||||
|
|
||||||
|
`WARPBOX_READ_HEADER_TIMEOUT` protects request headers. `WARPBOX_READ_TIMEOUT`
|
||||||
|
and `WARPBOX_WRITE_TIMEOUT` cover the whole upload/response lifetime in Go, so
|
||||||
|
small values can cause browser errors such as `NS_ERROR_NET_INTERRUPT` during
|
||||||
|
large transfers. Upload size, daily, storage, and box limits still enforce abuse
|
||||||
|
controls independently of these timeout values.
|
||||||
|
|
||||||
## Ban Behavior
|
## Ban Behavior
|
||||||
|
|
||||||
Active bans return:
|
Active bans return:
|
||||||
|
|||||||
@@ -11,26 +11,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppName string
|
AppName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
Environment string
|
Environment string
|
||||||
Addr string
|
Addr string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
DataDir string
|
DataDir string
|
||||||
AdminToken string
|
AdminToken string
|
||||||
StaticDir string
|
StaticDir string
|
||||||
TemplateDir string
|
TemplateDir string
|
||||||
ReadTimeout time.Duration
|
ReadHeaderTimeout time.Duration
|
||||||
WriteTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
IdleTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
TrustedProxies []string
|
IdleTimeout time.Duration
|
||||||
JobsEnabled bool
|
TrustedProxies []string
|
||||||
CleanupEnabled bool
|
JobsEnabled bool
|
||||||
CleanupEvery time.Duration
|
CleanupEnabled bool
|
||||||
ThumbnailEnabled bool
|
CleanupEvery time.Duration
|
||||||
ThumbnailEvery time.Duration
|
ThumbnailEnabled bool
|
||||||
MaxUploadSize int64
|
ThumbnailEvery time.Duration
|
||||||
DefaultSettings SettingsDefaults
|
MaxUploadSize int64
|
||||||
|
DefaultSettings SettingsDefaults
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsDefaults struct {
|
type SettingsDefaults struct {
|
||||||
@@ -55,25 +56,26 @@ type SettingsDefaults struct {
|
|||||||
|
|
||||||
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"),
|
||||||
AppVersion: envString("APP_VERSION", "dev"),
|
AppVersion: envString("APP_VERSION", "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")),
|
||||||
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
|
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
|
||||||
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
||||||
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
||||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second),
|
||||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0),
|
||||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
|
||||||
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||||
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
||||||
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
||||||
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
||||||
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
||||||
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
||||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
||||||
|
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||||
DefaultSettings: SettingsDefaults{
|
DefaultSettings: SettingsDefaults{
|
||||||
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
||||||
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseMegabytes(t *testing.T) {
|
func TestParseMegabytes(t *testing.T) {
|
||||||
tests := map[string]int64{
|
tests := map[string]int64{
|
||||||
@@ -49,3 +52,20 @@ func TestEnvBool(t *testing.T) {
|
|||||||
t.Fatalf("envBool() did not fall back to true")
|
t.Fatalf("envBool() did not fall back to true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultsUseLargeUploadFriendlyTimeouts(t *testing.T) {
|
||||||
|
t.Setenv("WARPBOX_BASE_URL", "http://example.test")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ReadHeaderTimeout != 15*time.Second {
|
||||||
|
t.Fatalf("ReadHeaderTimeout = %s, want 15s", cfg.ReadHeaderTimeout)
|
||||||
|
}
|
||||||
|
if cfg.ReadTimeout != 0 {
|
||||||
|
t.Fatalf("ReadTimeout = %s, want 0 for long uploads", cfg.ReadTimeout)
|
||||||
|
}
|
||||||
|
if cfg.WriteTimeout != 0 {
|
||||||
|
t.Fatalf("WriteTimeout = %s, want 0 for long uploads", cfg.WriteTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -707,24 +707,60 @@ func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
|
|||||||
for i, bar := range overview.UploadDays {
|
for i, bar := range overview.UploadDays {
|
||||||
want := 0
|
want := 0
|
||||||
if i == len(overview.UploadDays)-1 {
|
if i == len(overview.UploadDays)-1 {
|
||||||
want = 100
|
want = 150
|
||||||
}
|
}
|
||||||
if bar.Height != want {
|
if bar.HeightPx != want {
|
||||||
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want)
|
t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, bar := range overview.StorageDays {
|
for i, bar := range overview.StorageDays {
|
||||||
want := 0
|
want := 0
|
||||||
if i == len(overview.StorageDays)-1 {
|
if i == len(overview.StorageDays)-1 {
|
||||||
want = 100
|
want = 150
|
||||||
}
|
}
|
||||||
if bar.Height != want {
|
if bar.HeightPx != want {
|
||||||
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want)
|
t.Fatalf("storage bar %d height = %d, want %d", i, bar.HeightPx, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if overview.StatusBars[0].WidthPercent != 100 {
|
||||||
|
t.Fatalf("active status width = %d, want 100", overview.StatusBars[0].WidthPercent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminOverviewRendersBarHeightVariables(t *testing.T) {
|
func TestAdminOverviewChartsScaleRelativeToVisibleRange(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
|
||||||
|
yesterday := today.AddDate(0, 0, -1)
|
||||||
|
twoDaysAgo := today.AddDate(0, 0, -2)
|
||||||
|
boxes := []services.AdminBox{
|
||||||
|
{ID: "today-1", CreatedAt: today, TotalSize: 30},
|
||||||
|
{ID: "today-2", CreatedAt: today, TotalSize: 30},
|
||||||
|
{ID: "today-3", CreatedAt: today, TotalSize: 30},
|
||||||
|
{ID: "yesterday-1", CreatedAt: yesterday, TotalSize: 20},
|
||||||
|
{ID: "yesterday-2", CreatedAt: yesterday, TotalSize: 20},
|
||||||
|
{ID: "two-days-ago", CreatedAt: twoDaysAgo, TotalSize: 10},
|
||||||
|
}
|
||||||
|
overview := buildAdminOverview(boxes, services.AdminStats{TotalBoxes: 6, ExpiredBoxes: 2, ProtectedBoxes: 1})
|
||||||
|
|
||||||
|
last := len(overview.UploadDays) - 1
|
||||||
|
if overview.UploadDays[last].HeightPx != 150 {
|
||||||
|
t.Fatalf("3-upload day height = %d, want 150", overview.UploadDays[last].HeightPx)
|
||||||
|
}
|
||||||
|
if overview.UploadDays[last-1].HeightPx != 100 {
|
||||||
|
t.Fatalf("2-upload day height = %d, want 100", overview.UploadDays[last-1].HeightPx)
|
||||||
|
}
|
||||||
|
if overview.UploadDays[last-2].HeightPx != 50 {
|
||||||
|
t.Fatalf("1-upload day height = %d, want 50", overview.UploadDays[last-2].HeightPx)
|
||||||
|
}
|
||||||
|
if overview.StorageDays[last].HeightPx != 150 || overview.StorageDays[last-1].HeightPx != 66 || overview.StorageDays[last-2].HeightPx != 16 {
|
||||||
|
t.Fatalf("storage heights = %d/%d/%d, want 150/66/16", overview.StorageDays[last].HeightPx, overview.StorageDays[last-1].HeightPx, overview.StorageDays[last-2].HeightPx)
|
||||||
|
}
|
||||||
|
if overview.StatusBars[0].WidthPercent != 100 || overview.StatusBars[1].WidthPercent != 50 || overview.StatusBars[2].WidthPercent != 25 {
|
||||||
|
t.Fatalf("status widths = %d/%d/%d, want 100/50/25", overview.StatusBars[0].WidthPercent, overview.StatusBars[1].WidthPercent, overview.StatusBars[2].WidthPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
adminToken := createAdminSession(t, app)
|
adminToken := createAdminSession(t, app)
|
||||||
@@ -738,11 +774,26 @@ func TestAdminOverviewRendersBarHeightVariables(t *testing.T) {
|
|||||||
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
|
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
body := response.Body.String()
|
body := response.Body.String()
|
||||||
if !strings.Contains(body, "--bar-height: 100%") {
|
if !strings.Contains(body, `style="height: 150px"`) {
|
||||||
t.Fatalf("admin overview did not render a full-height bar: %s", body)
|
t.Fatalf("admin overview did not render a full-height pixel bar: %s", body)
|
||||||
}
|
}
|
||||||
if strings.Contains(body, `style="height:`) {
|
if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) {
|
||||||
t.Fatalf("admin overview still uses fragile percent height styles: %s", body)
|
t.Fatalf("admin overview did not render chart fallback data attributes: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `style="height: 0px"`) {
|
||||||
|
t.Fatalf("admin overview did not render zero pixel bars: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `style="width: 100%"`) {
|
||||||
|
t.Fatalf("admin overview did not render a full-width status bar: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `data-width-percent="100"`) || !strings.Contains(body, `data-stat-value=`) {
|
||||||
|
t.Fatalf("admin overview did not render status fallback data attributes: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "--bar-height") {
|
||||||
|
t.Fatalf("admin overview still uses css variable bar heights: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "/static/js/25-admin-charts.js?version=test") {
|
||||||
|
t.Fatalf("admin overview did not load chart fallback script: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,15 +159,17 @@ type adminOverview struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type adminChartBar struct {
|
type adminChartBar struct {
|
||||||
Label string
|
Label string
|
||||||
Value string
|
Value string
|
||||||
Height int // 0-100, percent of the tallest bar
|
HeightPx int
|
||||||
|
RawValue int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminStatBar struct {
|
type adminStatBar struct {
|
||||||
Label string
|
Label string
|
||||||
Value string
|
Value string
|
||||||
Percent int
|
RawValue int
|
||||||
|
WidthPercent int
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminBoxView struct {
|
type adminBoxView struct {
|
||||||
@@ -336,6 +338,7 @@ func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxVie
|
|||||||
// status distributions for the overview dashboard.
|
// status distributions for the overview dashboard.
|
||||||
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
|
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
|
||||||
const days = 14
|
const days = 14
|
||||||
|
const chartMaxHeightPx = 150
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
@@ -374,14 +377,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
|
|||||||
storageDays := make([]adminChartBar, days)
|
storageDays := make([]adminChartBar, days)
|
||||||
for i := 0; i < days; i++ {
|
for i := 0; i < days; i++ {
|
||||||
uploadDays[i] = adminChartBar{
|
uploadDays[i] = adminChartBar{
|
||||||
Label: labels[i],
|
Label: labels[i],
|
||||||
Value: strconv.Itoa(counts[i]),
|
Value: strconv.Itoa(counts[i]),
|
||||||
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
|
HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx),
|
||||||
|
RawValue: int64(counts[i]),
|
||||||
}
|
}
|
||||||
storageDays[i] = adminChartBar{
|
storageDays[i] = adminChartBar{
|
||||||
Label: labels[i],
|
Label: labels[i],
|
||||||
Value: helpers.FormatBytes(bytes[i]),
|
Value: helpers.FormatBytes(bytes[i]),
|
||||||
Height: scaleHeight(bytes[i], maxBytes),
|
HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx),
|
||||||
|
RawValue: bytes[i],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,10 +394,11 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
|
|||||||
if activeBoxes < 0 {
|
if activeBoxes < 0 {
|
||||||
activeBoxes = 0
|
activeBoxes = 0
|
||||||
}
|
}
|
||||||
|
maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes)
|
||||||
statusBars := []adminStatBar{
|
statusBars := []adminStatBar{
|
||||||
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)},
|
{Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)},
|
||||||
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)},
|
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)},
|
||||||
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)},
|
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)},
|
||||||
}
|
}
|
||||||
|
|
||||||
return adminOverview{
|
return adminOverview{
|
||||||
@@ -402,13 +408,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scaleHeight(value, max int64) int {
|
func scaleHeightPx(value, max int64, maxHeightPx int) int {
|
||||||
if max <= 0 || value <= 0 {
|
if max <= 0 || value <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
height := int(value * 100 / max)
|
height := int(value * int64(maxHeightPx) / max)
|
||||||
if height < 4 {
|
if height < 8 {
|
||||||
height = 4
|
height = 8
|
||||||
|
}
|
||||||
|
if height > maxHeightPx {
|
||||||
|
return maxHeightPx
|
||||||
}
|
}
|
||||||
return height
|
return height
|
||||||
}
|
}
|
||||||
@@ -420,6 +429,16 @@ func percentOf(value, total int) int {
|
|||||||
return value * 100 / total
|
return value * 100 / total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maxInt(values ...int) int {
|
||||||
|
max := 0
|
||||||
|
for _, value := range values {
|
||||||
|
if value > max {
|
||||||
|
max = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.requireAdmin(w, r) {
|
if !a.requireAdmin(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -54,11 +54,12 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadTimeout: cfg.ReadTimeout,
|
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||||
WriteTimeout: cfg.WriteTimeout,
|
ReadTimeout: cfg.ReadTimeout,
|
||||||
IdleTimeout: cfg.IdleTimeout,
|
WriteTimeout: cfg.WriteTimeout,
|
||||||
|
IdleTimeout: cfg.IdleTimeout,
|
||||||
}
|
}
|
||||||
server.RegisterOnShutdown(func() {
|
server.RegisterOnShutdown(func() {
|
||||||
stopJobs()
|
stopJobs()
|
||||||
|
|||||||
@@ -198,9 +198,9 @@
|
|||||||
.bar-chart {
|
.bar-chart {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(14, minmax(0, 1fr));
|
grid-template-columns: repeat(14, minmax(0, 1fr));
|
||||||
align-items: stretch;
|
align-items: end;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
min-height: 15rem;
|
min-height: 13rem;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -214,11 +214,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bar-chart-track {
|
.bar-chart-track {
|
||||||
position: relative;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1.8rem;
|
max-width: 1.8rem;
|
||||||
min-height: 9rem;
|
height: 150px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
|
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
|
||||||
border-radius: 0.45rem 0.45rem 0 0;
|
border-radius: 0.45rem 0.45rem 0 0;
|
||||||
@@ -228,12 +230,8 @@
|
|||||||
|
|
||||||
.bar-chart-bar {
|
.bar-chart-bar {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--bar-height, 0%);
|
min-height: 0;
|
||||||
border-radius: 6px 6px 0 0;
|
border-radius: 6px 6px 0 0;
|
||||||
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
|
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
|
||||||
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
|
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
|
||||||
@@ -278,6 +276,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-bar-track {
|
.stat-bar-track {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
height: 0.55rem;
|
height: 0.55rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -288,6 +288,7 @@
|
|||||||
.stat-bar-fill {
|
.stat-bar-fill {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--primary, #8b5cf6);
|
background: var(--primary, #8b5cf6);
|
||||||
}
|
}
|
||||||
|
|||||||
57
backend/static/js/25-admin-charts.js
Normal file
57
backend/static/js/25-admin-charts.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
(function () {
|
||||||
|
const maxBarHeight = 150;
|
||||||
|
|
||||||
|
function numberAttr(element, name) {
|
||||||
|
const value = Number(element.getAttribute(name));
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChartBars() {
|
||||||
|
document.querySelectorAll(".bar-chart").forEach((chart) => {
|
||||||
|
const bars = Array.from(chart.querySelectorAll(".bar-chart-col"));
|
||||||
|
const maxValue = Math.max(0, ...bars.map((bar) => numberAttr(bar, "data-chart-value")));
|
||||||
|
|
||||||
|
bars.forEach((bar) => {
|
||||||
|
const fill = bar.querySelector(".bar-chart-bar");
|
||||||
|
if (!fill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = numberAttr(bar, "data-chart-value");
|
||||||
|
let height = numberAttr(fill, "data-height-px");
|
||||||
|
if (maxValue > 0) {
|
||||||
|
height = value <= 0 ? 0 : Math.max(8, Math.round((value / maxValue) * maxBarHeight));
|
||||||
|
}
|
||||||
|
fill.style.height = `${Math.min(maxBarHeight, height)}px`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatusBars() {
|
||||||
|
const rows = Array.from(document.querySelectorAll(".stat-bar"));
|
||||||
|
const maxValue = Math.max(0, ...rows.map((row) => numberAttr(row, "data-stat-value")));
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const fill = row.querySelector(".stat-bar-fill");
|
||||||
|
if (!fill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = numberAttr(row, "data-stat-value");
|
||||||
|
let width = numberAttr(fill, "data-width-percent");
|
||||||
|
if (maxValue > 0) {
|
||||||
|
width = value <= 0 ? 0 : Math.round((value / maxValue) * 100);
|
||||||
|
}
|
||||||
|
fill.style.width = `${Math.max(0, Math.min(100, width))}%`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
applyChartBars();
|
||||||
|
applyStatusBars();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||||
|
|||||||
@@ -65,9 +65,9 @@
|
|||||||
<p class="muted-copy">New boxes created over the last 14 days.</p>
|
<p class="muted-copy">New boxes created over the last 14 days.</p>
|
||||||
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
|
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
|
||||||
{{range .Data.Overview.UploadDays}}
|
{{range .Data.Overview.UploadDays}}
|
||||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
|
||||||
<span class="bar-chart-value">{{.Value}}</span>
|
<span class="bar-chart-value">{{.Value}}</span>
|
||||||
<span class="bar-chart-track"><span class="bar-chart-bar" style="--bar-height: {{.Height}}%"></span></span>
|
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
|
||||||
<span class="bar-chart-label">{{.Label}}</span>
|
<span class="bar-chart-label">{{.Label}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
|
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
|
||||||
<div class="stat-bars">
|
<div class="stat-bars">
|
||||||
{{range .Data.Overview.StatusBars}}
|
{{range .Data.Overview.StatusBars}}
|
||||||
<div class="stat-bar">
|
<div class="stat-bar" data-stat-value="{{.RawValue}}">
|
||||||
<span>{{.Label}} <strong>{{.Value}}</strong></span>
|
<span>{{.Label}} <strong>{{.Value}}</strong></span>
|
||||||
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
|
<span class="stat-bar-track"><span class="stat-bar-fill" data-width-percent="{{.WidthPercent}}" style="width: {{.WidthPercent}}%"></span></span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,9 +97,9 @@
|
|||||||
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
|
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
|
||||||
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
|
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
|
||||||
{{range .Data.Overview.StorageDays}}
|
{{range .Data.Overview.StorageDays}}
|
||||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
|
||||||
<span class="bar-chart-value">{{.Value}}</span>
|
<span class="bar-chart-value">{{.Value}}</span>
|
||||||
<span class="bar-chart-track"><span class="bar-chart-bar" style="--bar-height: {{.Height}}%"></span></span>
|
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
|
||||||
<span class="bar-chart-label">{{.Label}}</span>
|
<span class="bar-chart-label">{{.Label}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
5
scripts/env/dev.env.example
vendored
5
scripts/env/dev.env.example
vendored
@@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
|
|||||||
WARPBOX_SHORT_WINDOW_SECONDS=60
|
WARPBOX_SHORT_WINDOW_SECONDS=60
|
||||||
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
|
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
|
||||||
WARPBOX_USER_STORAGE_BACKEND=local
|
WARPBOX_USER_STORAGE_BACKEND=local
|
||||||
WARPBOX_READ_TIMEOUT=15s
|
WARPBOX_READ_HEADER_TIMEOUT=15s
|
||||||
WARPBOX_WRITE_TIMEOUT=60s
|
WARPBOX_READ_TIMEOUT=0s
|
||||||
|
WARPBOX_WRITE_TIMEOUT=0s
|
||||||
WARPBOX_IDLE_TIMEOUT=120s
|
WARPBOX_IDLE_TIMEOUT=120s
|
||||||
WARPBOX_TRUSTED_PROXIES=
|
WARPBOX_TRUSTED_PROXIES=
|
||||||
|
|||||||
Reference in New Issue
Block a user