6 Commits

Author SHA1 Message Date
1ab5021667 feat(config): support large uploads with read header timeout
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Disable default read and write timeouts (set to 0s) to prevent Go from
prematurely closing connections during large multi-GB uploads.

Introduce `WARPBOX_READ_HEADER_TIMEOUT` (defaulting to 15s) to protect
against slowloris-style attacks while still allowing long-running
uploads to complete. Update documentation and example configurations
accordingly.
2026-06-01 15:23:28 +03:00
c9f865cd85 refactor(admin): use inline pixel heights for overview charts
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Refactors the admin overview dashboard charts to use inline pixel heights (up to 150px) instead of CSS variables and percentage-based heights. This provides more robust rendering and layout control.

Changes include:
- Replacing `Height` with `HeightPx` in chart bar structures.
- Rendering inline styles for height and width on charts and status bars.
- Adding fallback data attributes (`data-height-px`, `data-chart-value`, etc.) and loading a new fallback script (`25-admin-charts.js`).
- Updating and expanding test coverage to assert correct scaling and HTML rendering.
2026-06-01 12:30:59 +03:00
38afc6c34d feat(admin): exclude health check entries from admin logs
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
Filter out automated health check log entries (such as `/health`,
`/healthz`, and `/api/v1/health`) from the admin logs view. This
reduces noise in the dashboard caused by frequent container health
pings.

Also added corresponding unit tests to verify the filtering behavior.
2026-06-01 12:04:36 +03:00
9a5be44a7f refactor(admin): use CSS custom properties for bar chart heights
Refactors the admin dashboard bar charts to use CSS custom properties (`--bar-height`) instead of fragile inline `height` styles.

Changes include:
- Updating the HTML templates to pass the height as a CSS variable.
- Converting the `.bar-chart` layout from Flexbox to CSS Grid for more consistent column distribution.
- Using absolute positioning for `.bar-chart-bar` inside `.bar-chart-track`.
- Adding a Go test to verify that the dashboard renders the CSS variable and no longer uses inline height styles.
2026-06-01 12:01:39 +03:00
48722f0aab refactor(backend/handlers): use withRequestLogAttrs helper for logging
Replace manual IP logging using `uploadClientIP(r)` with the
`withRequestLogAttrs` helper function in `manage.go`. This simplifies
the log statements and standardizes the extraction of request-related
attributes.
2026-06-01 11:46:34 +03:00
94cf9531fa refactor(handlers): standardize logging using request attributes helper
- Replace manual IP logging with the `withRequestLogAttrs` helper in authentication handlers.
- Add user activity logging for API documentation and login page views.
- Clean up log calls to use variadic expansion of request attributes.
2026-06-01 11:30:38 +03:00
22 changed files with 461 additions and 216 deletions

View File

@@ -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=

View File

@@ -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`:

View File

@@ -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:

View File

@@ -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),

View File

@@ -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)
}
}

View File

@@ -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})

View File

@@ -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
@@ -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"),

View File

@@ -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.",

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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 {

View 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
}

View File

@@ -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
} }

View File

@@ -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{

View File

@@ -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)

View File

@@ -50,16 +50,16 @@ 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,
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()

View File

@@ -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(),
)
})
}
}

View File

@@ -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);
} }

View 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();
}
})();

View File

@@ -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>

View File

@@ -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}}

View File

@@ -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=