Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ab5021667 | |||
| c9f865cd85 | |||
| 38afc6c34d | |||
| 9a5be44a7f | |||
| 48722f0aab | |||
| 94cf9531fa |
@@ -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:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
AdminToken string
|
AdminToken string
|
||||||
StaticDir string
|
StaticDir string
|
||||||
TemplateDir string
|
TemplateDir string
|
||||||
|
ReadHeaderTimeout time.Duration
|
||||||
ReadTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
WriteTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
@@ -64,8 +65,9 @@ func Load() (Config, error) {
|
|||||||
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),
|
||||||
|
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
|
||||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||||
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
||||||
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
||||||
|
|||||||
@@ -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,21 +707,94 @@ 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 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)
|
||||||
|
defer cleanup()
|
||||||
|
adminToken := createAdminSession(t, app)
|
||||||
|
uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.AdminDashboard(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, `style="height: 150px"`) {
|
||||||
|
t.Fatalf("admin overview did not render a full-height pixel bar: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
|
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
|
||||||
@@ -1016,6 +1089,7 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
|||||||
lines := strings.Join([]string{
|
lines := strings.Join([]string{
|
||||||
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
|
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
|
||||||
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
|
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
|
||||||
|
`{"date":"2026-05-31","time":"12:36:56","source":"http","severity":"dev","code":200,"log":"http request","method":"GET","path":"/health","ip":"127.0.0.1","user_agent":"Wget"}`,
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
|
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
|
||||||
@@ -1036,6 +1110,16 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
|||||||
if strings.Contains(logsBody, "172.30.0.1:48358") {
|
if strings.Contains(logsBody, "172.30.0.1:48358") {
|
||||||
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
|
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
|
||||||
}
|
}
|
||||||
|
healthRequest := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
|
||||||
|
healthRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
healthResponse := httptest.NewRecorder()
|
||||||
|
app.AdminLogs(healthResponse, healthRequest)
|
||||||
|
if healthResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AdminLogs health status = %d, body = %s", healthResponse.Code, healthResponse.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(healthResponse.Body.String(), "/health") || strings.Contains(healthResponse.Body.String(), "Wget") {
|
||||||
|
t.Fatalf("AdminLogs rendered container health ping: %s", healthResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
|
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
|
||||||
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
|||||||
@@ -161,13 +161,15 @@ 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)
|
||||||
|
|
||||||
@@ -376,12 +379,14 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
|
|||||||
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
|
||||||
@@ -1731,11 +1750,29 @@ func readLogEntries(file string) ([]adminLogEntry, error) {
|
|||||||
if err := json.Unmarshal(line, &raw); err != nil {
|
if err := json.Unmarshal(line, &raw); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isHealthCheckLogEntry(raw) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
entries = append(entries, logEntryFromMap(raw))
|
entries = append(entries, logEntryFromMap(raw))
|
||||||
}
|
}
|
||||||
return entries, scanner.Err()
|
return entries, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isHealthCheckLogEntry(raw map[string]any) bool {
|
||||||
|
path := strings.TrimSpace(firstLogString(raw, "path", "route"))
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fields := strings.Fields(path)
|
||||||
|
if len(fields) > 0 {
|
||||||
|
path = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||||
|
path = path[:idx]
|
||||||
|
}
|
||||||
|
return path == "/health" || path == "/healthz" || path == "/api/v1/health"
|
||||||
|
}
|
||||||
|
|
||||||
func logEntryFromMap(raw map[string]any) adminLogEntry {
|
func logEntryFromMap(raw map[string]any) adminLogEntry {
|
||||||
entry := adminLogEntry{
|
entry := adminLogEntry{
|
||||||
Date: logString(raw, "date"),
|
Date: logString(raw, "date"),
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ type apiDocsData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, loggedIn := a.currentUser(r)
|
||||||
|
actor := "anonymous"
|
||||||
|
if loggedIn {
|
||||||
|
actor = "user"
|
||||||
|
}
|
||||||
|
a.logger.Info("api docs viewed", withRequestLogAttrs(r,
|
||||||
|
"source", "page",
|
||||||
|
"severity", "user_activity",
|
||||||
|
"code", 2501,
|
||||||
|
"actor", actor,
|
||||||
|
"user_id", user.ID,
|
||||||
|
)...)
|
||||||
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
|
||||||
Title: "API documentation",
|
Title: "API documentation",
|
||||||
Description: "Curl and ShareX upload examples for Warpbox.",
|
Description: "Curl and ShareX upload examples for Warpbox.",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||||
a.logger.Warn("registration rate limited", "source", "auth", "severity", "warn", "code", 4291, "ip", uploadClientIP(r))
|
a.logger.Warn("registration rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4291)...)
|
||||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -45,11 +45,11 @@ func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("bootstrap registration failed", "source", "auth", "severity", "warn", "code", 4400, "ip", uploadClientIP(r), "email", r.FormValue("email"), "error", err.Error())
|
a.logger.Warn("bootstrap registration failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4400, "email", r.FormValue("email"), "error", err.Error())...)
|
||||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID, "ip", uploadClientIP(r))
|
a.logger.Info("first admin created", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)...)
|
||||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +58,13 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.logger.Info("login page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2503, "actor", "anonymous")...)
|
||||||
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||||
a.logger.Warn("login rate limited", "source", "auth", "severity", "warn", "code", 4292, "ip", uploadClientIP(r), "email", r.FormValue("email"))
|
a.logger.Warn("login rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4292, "email", r.FormValue("email"))...)
|
||||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -77,13 +78,13 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"), "ip", uploadClientIP(r))
|
a.logger.Warn("login failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))...)
|
||||||
a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed")
|
a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed")
|
||||||
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.setUserSessionCookie(w, r, token)
|
a.setUserSessionCookie(w, r, token)
|
||||||
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID, "ip", uploadClientIP(r))
|
a.logger.Info("user login", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)...)
|
||||||
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user, ok := a.currentUser(r); ok {
|
if user, ok := a.currentUser(r); ok {
|
||||||
a.logger.Info("user logout", "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID, "ip", uploadClientIP(r))
|
a.logger.Info("user logout", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID)...)
|
||||||
}
|
}
|
||||||
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
||||||
_ = a.authService.Logout(cookie.Value)
|
_ = a.authService.Logout(cookie.Value)
|
||||||
@@ -107,6 +108,7 @@ func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.logger.Info("invite page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2504, "invite_email", invite.Email, "reset", invite.UserID != "")...)
|
||||||
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
|||||||
token := r.PathValue("token")
|
token := r.PathValue("token")
|
||||||
invite, err := a.authService.InviteByToken(token)
|
invite, err := a.authService.InviteByToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("invite accept invalid", "source", "auth", "severity", "warn", "code", 4404, "ip", uploadClientIP(r))
|
a.logger.Warn("invite accept invalid", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4404)...)
|
||||||
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,11 +126,11 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("invite accept failed", "source", "auth", "severity", "warn", "code", 4405, "ip", uploadClientIP(r), "invite_email", invite.Email, "error", err.Error())
|
a.logger.Warn("invite accept failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4405, "invite_email", invite.Email, "error", err.Error())...)
|
||||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "ip", uploadClientIP(r), "invite_email", invite.Email)
|
a.logger.Info("invite accepted", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "invite_email", invite.Email)...)
|
||||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.logger.Info("account settings viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2505, "user_id", user.ID)...)
|
||||||
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,11 +173,11 @@ func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
|
a.logger.Warn("api token create failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())...)
|
||||||
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
|
a.logger.Info("api token created", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)...)
|
||||||
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +187,9 @@ func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
||||||
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
a.logger.Warn("api token delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())...)
|
||||||
} else {
|
} else {
|
||||||
a.logger.Info("api token deleted", "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))
|
a.logger.Info("api token deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))...)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -233,16 +236,16 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
||||||
a.logger.Warn("password change failed current password", "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID, "ip", uploadClientIP(r))
|
a.logger.Warn("password change failed current password", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID)...)
|
||||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
||||||
a.logger.Warn("password change failed", "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())
|
a.logger.Warn("password change failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())...)
|
||||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("password changed", "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID, "ip", uploadClientIP(r))
|
a.logger.Info("password changed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID)...)
|
||||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.logger.Info("user dashboard viewed", withRequestLogAttrs(r,
|
||||||
|
"source", "page",
|
||||||
|
"severity", "user_activity",
|
||||||
|
"code", 2502,
|
||||||
|
"user_id", user.ID,
|
||||||
|
)...)
|
||||||
collections, err := a.authService.ListCollections(user.ID)
|
collections, err := a.authService.ListCollections(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "unable to load collections", http.StatusInternalServerError)
|
http.Error(w, "unable to load collections", http.StatusInternalServerError)
|
||||||
@@ -112,9 +118,9 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
||||||
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
|
a.logger.Warn("collection create failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())...)
|
||||||
} else {
|
} else {
|
||||||
a.logger.Info("collection created", "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))
|
a.logger.Info("collection created", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))...)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -129,11 +135,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
||||||
a.logger.Warn("owned box rename failed", "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
a.logger.Warn("owned box rename failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
|
||||||
a.handleUserBoxError(w, r, err)
|
a.handleUserBoxError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("owned box renamed", "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
a.logger.Info("owned box renamed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
|
||||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,16 +154,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
collectionID := r.FormValue("collection_id")
|
collectionID := r.FormValue("collection_id")
|
||||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||||
a.logger.Warn("owned box move invalid collection", "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
a.logger.Warn("owned box move invalid collection", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
|
||||||
http.Error(w, "collection not found", http.StatusForbidden)
|
http.Error(w, "collection not found", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
||||||
a.logger.Warn("owned box move failed", "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
a.logger.Warn("owned box move failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
|
||||||
a.handleUserBoxError(w, r, err)
|
a.handleUserBoxError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("owned box moved", "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
a.logger.Info("owned box moved", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
|
||||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,11 +173,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||||
a.logger.Warn("owned box delete failed", "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
a.logger.Warn("owned box delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
|
||||||
a.handleUserBoxError(w, r, err)
|
a.handleUserBoxError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("owned box deleted", "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
a.logger.Info("owned box deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
|
||||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ type previewPageData struct {
|
|||||||
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadPage(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 {
|
||||||
a.logger.Warn("download page missing box", "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
a.logger.Warn("download page missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"))...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
if err := a.uploadService.CanDownload(box); err != nil {
|
||||||
a.logger.Warn("download page unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
a.logger.Warn("download page unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
|
||||||
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
|
a.renderPage(w, r, 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.",
|
||||||
@@ -101,7 +101,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
ExpiresLabel: expiresLabel,
|
ExpiresLabel: expiresLabel,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
a.logger.Info("download page viewed", "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "ip", uploadClientIP(r), "locked", locked)
|
a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func plural(n int) string {
|
func plural(n int) string {
|
||||||
@@ -139,7 +139,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
DownloadURL: view.DownloadURL,
|
DownloadURL: view.DownloadURL,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
a.logger.Info("file preview page viewed", "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
a.logger.Info("file preview page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -148,13 +148,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||||
a.logger.Warn("protected file download blocked", "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
a.logger.Warn("protected file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
http.Error(w, "password required", http.StatusUnauthorized)
|
http.Error(w, "password required", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
||||||
a.logger.Info("file content served", "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "attachment", r.URL.Query().Get("inline") != "1")
|
a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -202,7 +202,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
|
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, "ip", uploadClientIP(r))
|
a.logger.Warn("box unlock failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)...)
|
||||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -215,26 +215,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
Secure: r.TLS != nil,
|
Secure: r.TLS != nil,
|
||||||
Expires: box.ExpiresAt,
|
Expires: box.ExpiresAt,
|
||||||
})
|
})
|
||||||
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID, "ip", uploadClientIP(r))
|
a.logger.Info("box unlocked", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)...)
|
||||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
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) {
|
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("file request missing box", "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
a.logger.Warn("file request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"))...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, services.File{}, false
|
return services.Box{}, services.File{}, false
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
if err := a.uploadService.CanDownload(box); err != nil {
|
||||||
a.logger.Warn("file request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r), "error", err.Error())
|
a.logger.Warn("file request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "error", err.Error())...)
|
||||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||||
return services.Box{}, services.File{}, false
|
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 {
|
||||||
a.logger.Warn("file request missing file", "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
a.logger.Warn("file request missing file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"))...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, services.File{}, false
|
return services.Box{}, services.File{}, false
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
|
|||||||
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
|
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
|
||||||
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
|
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("file object missing", "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "error", err.Error())
|
a.logger.Warn("file object missing", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -280,17 +280,17 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
|||||||
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadZip(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 {
|
||||||
a.logger.Warn("zip request missing box", "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
if err := a.uploadService.CanDownload(box); err != nil {
|
||||||
a.logger.Warn("zip request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
a.logger.Warn("zip request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
|
||||||
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) {
|
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||||
a.logger.Warn("protected zip download blocked", "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID, "ip", uploadClientIP(r))
|
a.logger.Warn("protected zip download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID)...)
|
||||||
http.Error(w, "password required", http.StatusUnauthorized)
|
http.Error(w, "password required", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
|||||||
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", "severity", "warn", "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())
|
||||||
}
|
}
|
||||||
a.logger.Info("zip downloaded", "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "ip", uploadClientIP(r), "files", len(box.Files))
|
a.logger.Info("zip downloaded", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "files", len(box.Files))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) fileView(box services.Box, file services.File) fileView {
|
func (a *App) fileView(box services.Box, file services.File) fileView {
|
||||||
|
|||||||
29
backend/libs/handlers/logging.go
Normal file
29
backend/libs/handlers/logging.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requestLogAttrs(r *http.Request) []any {
|
||||||
|
attrs := []any{
|
||||||
|
"ip", uploadClientIP(r),
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
}
|
||||||
|
if requestID := middleware.RequestIDFromContext(r.Context()); requestID != "" {
|
||||||
|
attrs = append(attrs, "request_id", requestID)
|
||||||
|
}
|
||||||
|
if userAgent := r.UserAgent(); userAgent != "" {
|
||||||
|
attrs = append(attrs, "user_agent", userAgent)
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func withRequestLogAttrs(r *http.Request, attrs ...any) []any {
|
||||||
|
out := make([]any, 0, len(attrs)+8)
|
||||||
|
out = append(out, attrs...)
|
||||||
|
out = append(out, requestLogAttrs(r)...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
Description: "Delete this anonymous Warpbox upload.",
|
Description: "Delete this anonymous Warpbox upload.",
|
||||||
Data: a.managePageData(box, r.PathValue("token")),
|
Data: a.managePageData(box, r.PathValue("token")),
|
||||||
})
|
})
|
||||||
a.logger.Info("anonymous manage page viewed", "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID, "ip", uploadClientIP(r))
|
a.logger.Info("anonymous manage page viewed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -41,11 +41,11 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
|
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
|
||||||
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
a.logger.Warn("anonymous delete failed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error())...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.logger.Info("anonymous box deleted", "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID, "ip", uploadClientIP(r))
|
a.logger.Info("anonymous box deleted", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID)...)
|
||||||
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
|
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +60,12 @@ func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
|
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("anonymous manage missing box", "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
a.logger.Warn("anonymous manage missing box", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"))...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, false
|
return services.Box{}, false
|
||||||
}
|
}
|
||||||
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
|
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
|
||||||
a.logger.Warn("anonymous manage invalid token", "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID, "ip", uploadClientIP(r))
|
a.logger.Warn("anonymous manage invalid token", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID)...)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, false
|
return services.Box{}, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
|
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
actor := "anonymous"
|
||||||
|
if loggedIn {
|
||||||
|
actor = "user"
|
||||||
|
}
|
||||||
|
a.logger.Info("upload page viewed", withRequestLogAttrs(r,
|
||||||
|
"source", "page",
|
||||||
|
"severity", "user_activity",
|
||||||
|
"code", 2500,
|
||||||
|
"actor", actor,
|
||||||
|
"user_id", user.ID,
|
||||||
|
)...)
|
||||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
||||||
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
||||||
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||||
if authErr != nil {
|
if authErr != nil {
|
||||||
a.logger.Warn("upload rejected invalid bearer token", "source", "user-upload", "severity", "warn", "code", 4010, "ip", uploadClientIP(r), "user_agent", r.UserAgent())
|
a.logger.Warn("upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4010)...)
|
||||||
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -30,14 +30,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
||||||
a.logger.Warn("anonymous upload rejected disabled", "source", "user-upload", "severity", "warn", "code", 4012, "ip", uploadClientIP(r))
|
a.logger.Warn("anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4012)...)
|
||||||
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
|
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
|
||||||
rateKey := uploadRateKey(r, user, loggedIn)
|
rateKey := uploadRateKey(r, user, loggedIn)
|
||||||
if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
||||||
a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID)
|
a.logger.Warn("upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4290, "user_id", user.ID)...)
|
||||||
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
|
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
parseLimit = 32 << 20
|
parseLimit = 32 << 20
|
||||||
}
|
}
|
||||||
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||||
a.logger.Warn("upload form parse failed", "source", "user-upload", "severity", "warn", "code", 4000, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...)
|
||||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,14 +65,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
ownerID = user.ID
|
ownerID = user.ID
|
||||||
collectionID = r.FormValue("collection_id")
|
collectionID = r.FormValue("collection_id")
|
||||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||||
a.logger.Warn("upload rejected invalid collection", "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)
|
a.logger.Warn("upload rejected invalid collection", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)...)
|
||||||
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isAdminUpload {
|
if !isAdminUpload {
|
||||||
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
|
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
|
||||||
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))
|
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))...)
|
||||||
helpers.WriteJSONError(w, status, message)
|
helpers.WriteJSONError(w, status, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
|
if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
|
||||||
a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)
|
a.logger.Warn("upload rejected expiration days", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4131, "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)...)
|
||||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,13 +99,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Only honour it for unlimited uploaders; otherwise it's an invalid value.
|
// Only honour it for unlimited uploaders; otherwise it's an invalid value.
|
||||||
if expiresMinutes < 0 || rawMaxDays < 0 {
|
if expiresMinutes < 0 || rawMaxDays < 0 {
|
||||||
if !unlimitedExpiry {
|
if !unlimitedExpiry {
|
||||||
a.logger.Warn("upload rejected unlimited expiration", "source", "user-upload", "severity", "warn", "code", 4133, "ip", uploadClientIP(r), "user_id", user.ID)
|
a.logger.Warn("upload rejected unlimited expiration", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4133, "user_id", user.ID)...)
|
||||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expiresMinutes = -1
|
expiresMinutes = -1
|
||||||
} else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 {
|
} else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 {
|
||||||
a.logger.Warn("upload rejected expiration minutes", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)
|
a.logger.Warn("upload rejected expiration minutes", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4132, "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)...)
|
||||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -123,12 +123,12 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
result, boxesAdded, status, policyMessage, err := a.createOrAppendBox(r, user, loggedIn, effectivePolicy, files, opts, !isAdminUpload)
|
result, boxesAdded, status, policyMessage, err := a.createOrAppendBox(r, user, loggedIn, effectivePolicy, files, opts, !isAdminUpload)
|
||||||
if policyMessage != "" {
|
if policyMessage != "" {
|
||||||
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))
|
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))...)
|
||||||
helpers.WriteJSONError(w, status, policyMessage)
|
helpers.WriteJSONError(w, status, policyMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
a.logger.Warn("upload failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4001, "user_id", user.ID, "error", err.Error())...)
|
||||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
||||||
a.logger.Info("upload response sent", "source", "user-upload", "severity", "user_activity", "code", 2001, "ip", uploadClientIP(r), "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload)
|
a.logger.Info("box uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2001, "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
|
||||||
|
|
||||||
if wantsJSON(r) {
|
if wantsJSON(r) {
|
||||||
helpers.WriteJSON(w, http.StatusCreated, result)
|
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
middleware.SecurityHeaders,
|
middleware.SecurityHeaders,
|
||||||
middleware.Gzip,
|
middleware.Gzip,
|
||||||
middleware.ClientIP(cfg.TrustedProxies),
|
middleware.ClientIP(cfg.TrustedProxies),
|
||||||
middleware.Logger(logger),
|
|
||||||
middleware.Bans(logger, banService, cfg.TrustedProxies),
|
middleware.Bans(logger, banService, cfg.TrustedProxies),
|
||||||
)
|
)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||||
ReadTimeout: cfg.ReadTimeout,
|
ReadTimeout: cfg.ReadTimeout,
|
||||||
WriteTimeout: cfg.WriteTimeout,
|
WriteTimeout: cfg.WriteTimeout,
|
||||||
IdleTimeout: cfg.IdleTimeout,
|
IdleTimeout: cfg.IdleTimeout,
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
|
||||||
)
|
|
||||||
|
|
||||||
type statusRecorder struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
status int
|
|
||||||
bytes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *statusRecorder) WriteHeader(status int) {
|
|
||||||
r.status = status
|
|
||||||
r.ResponseWriter.WriteHeader(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *statusRecorder) Write(data []byte) (int, error) {
|
|
||||||
if r.status == 0 {
|
|
||||||
r.status = http.StatusOK
|
|
||||||
}
|
|
||||||
n, err := r.ResponseWriter.Write(data)
|
|
||||||
r.bytes += n
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func Logger(logger *slog.Logger) Middleware {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
start := time.Now()
|
|
||||||
recorder := &statusRecorder{ResponseWriter: w}
|
|
||||||
|
|
||||||
next.ServeHTTP(recorder, r)
|
|
||||||
|
|
||||||
status := recorder.status
|
|
||||||
if status == 0 {
|
|
||||||
status = http.StatusOK
|
|
||||||
}
|
|
||||||
ip, ok := services.ClientIPFromContext(r)
|
|
||||||
if !ok {
|
|
||||||
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("http request",
|
|
||||||
"source", "http",
|
|
||||||
"severity", "dev",
|
|
||||||
"code", status,
|
|
||||||
"method", r.Method,
|
|
||||||
"path", r.URL.Path,
|
|
||||||
"status", status,
|
|
||||||
"bytes", recorder.bytes,
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"request_id", RequestIDFromContext(r.Context()),
|
|
||||||
"ip", ip,
|
|
||||||
"remote_addr", r.RemoteAddr,
|
|
||||||
"user_agent", r.UserAgent(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -196,49 +196,65 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bar-chart {
|
.bar-chart {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: stretch;
|
grid-template-columns: repeat(14, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
height: 180px;
|
min-height: 13rem;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-chart-col {
|
.bar-chart-col {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
flex-direction: column;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-chart-track {
|
.bar-chart-track {
|
||||||
width: 100%;
|
|
||||||
max-width: 2.2rem;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1.8rem;
|
||||||
|
height: 150px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
|
||||||
|
border-radius: 0.45rem 0.45rem 0 0;
|
||||||
|
background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--border) 55%, transparent));
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-chart-bar {
|
.bar-chart-bar {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: 6px 6px 0 0;
|
border-radius: 6px 6px 0 0;
|
||||||
background: linear-gradient(180deg, var(--primary, #8b5cf6), color-mix(in srgb, var(--primary, #8b5cf6) 55%, transparent));
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-chart-value {
|
.bar-chart-value {
|
||||||
|
min-height: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-chart-label {
|
.bar-chart-label {
|
||||||
|
overflow: hidden;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,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;
|
||||||
@@ -270,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="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="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