9 Commits

Author SHA1 Message Date
f628b489af feat: add emoji reaction support for files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m46s
- Implement `ReactionService` to manage file reactions in the database.
- Add `POST /d/{boxID}/f/{fileID}/react` endpoint to handle user reactions.
- Add `GET /emoji/{pack}/{file}` endpoint to serve custom emoji assets.
- Support loading custom emoji packs dynamically from the data directory.
- Update README with instructions on configuring emoji reaction packs.
2026-06-02 11:30:33 +03:00
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
60d2ea0204 fix(admin): improve overview bar chart layout and alignment
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m42s
2026-06-01 11:08:24 +03:00
ffa2d9636b feat(admin): add dashboard overview charts and log pagination
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Enhance the admin panel by introducing visual overview charts for upload and storage trends, along with status bars for system metrics.

Additionally, implement pagination for the admin logs view, allowing users to navigate through log entries with configurable page sizes. Corresponding CSS styles have been added for the new charts, metrics grid, and pagination controls.
2026-06-01 04:22:38 +03:00
36 changed files with 2048 additions and 289 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`.
@@ -49,6 +54,37 @@ links from `/admin/users`.
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it
at `/admin/login` if you need to recover access without a user session. at `/admin/login` if you need to recover access without a user session.
## Emoji reaction packs
File reactions use emoji packs from the runtime data directory, not from the application code. By
default that means `./data/emoji`; if you change `WARPBOX_DATA_DIR`, use
`$WARPBOX_DATA_DIR/emoji` instead.
Each folder under `./data/emoji` becomes one emoji tab in the reaction picker. Put image files
directly inside the pack folder:
```text
data/
├── db/
├── files/
├── logs/
└── emoji/
├── openmoji/
│ ├── 1F600.svg
│ ├── 1F44D.svg
│ └── 2764.svg
├── pixel-pack/
│ ├── happy.webp
│ ├── fire.webp
│ └── star.webp
└── custom-work/
├── approved.png
└── shipped.png
```
In this example, the picker shows tabs named `Openmoji`, `Pixel pack`, and `Custom work`.
Supported emoji image extensions are `.svg`, `.webp`, `.png`, `.jpg`, `.jpeg`, and `.gif`.
For one-off Go commands, run them from the backend module: For one-off Go commands, run them from the backend module:
```bash ```bash
@@ -106,6 +142,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

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

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

@@ -695,6 +695,108 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
} }
} }
func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
overview := buildAdminOverview([]services.AdminBox{{
ID: "box1",
CreatedAt: today,
TotalSize: 1024,
}}, services.AdminStats{TotalBoxes: 1, TotalFiles: 1, TotalSize: 1024})
for i, bar := range overview.UploadDays {
want := 0
if i == len(overview.UploadDays)-1 {
want = 150
}
if bar.HeightPx != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want)
}
}
for i, bar := range overview.StorageDays {
want := 0
if i == len(overview.StorageDays)-1 {
want = 150
}
if bar.HeightPx != 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) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -987,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 {
@@ -1007,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

@@ -17,6 +17,7 @@ import (
"strings" "strings"
"time" "time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs" "warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
@@ -36,6 +37,7 @@ type adminPageData struct {
StorageTypes []adminStorageProviderView StorageTypes []adminStorageProviderView
Logs adminLogsView Logs adminLogsView
Bans adminBansView Bans adminBansView
Overview adminOverview
Section string Section string
PageTitle string PageTitle string
LastInviteURL string LastInviteURL string
@@ -52,8 +54,24 @@ type adminLogsView struct {
Query string Query string
Sort string Sort string
TotalShown int TotalShown int
Total int
Page int
PerPage int
PerPageOptions []int
TotalPages int
RangeFrom int
RangeTo int
PageLinks []adminFilesPageLink
HasPrev bool
HasNext bool
PrevHref string
NextHref string
} }
var adminLogsPageSizes = []int{50, 100, 250, 500}
const adminLogsDefaultPageSize = 100
type adminLogEntry struct { type adminLogEntry struct {
Date string Date string
Time string Time string
@@ -134,6 +152,26 @@ type adminStorageProviderView struct {
Icon string Icon string
} }
type adminOverview struct {
UploadDays []adminChartBar
StorageDays []adminChartBar
StatusBars []adminStatBar
}
type adminChartBar struct {
Label string
Value string
HeightPx int
RawValue int64
}
type adminStatBar struct {
Label string
Value string
RawValue int
WidthPercent int
}
type adminBoxView struct { type adminBoxView struct {
ID string ID string
Owner string Owner string
@@ -248,25 +286,159 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError) http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return return
} }
boxes, err := a.adminBoxes(8) allBoxes, err := a.uploadService.AdminBoxes(0)
if err != nil { if err != nil {
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError) http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return return
} }
overview := buildAdminOverview(allBoxes, stats)
recent := a.recentBoxViews(allBoxes, 8)
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
Title: "Admin overview", Title: "Admin overview",
Description: "Warpbox admin overview.", Description: "Warpbox admin overview.",
CurrentUser: a.currentPublicUser(r), CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, Boxes: recent,
Overview: overview,
Section: "overview", Section: "overview",
PageTitle: "Admin overview", PageTitle: "Admin overview",
}, },
}) })
} }
// recentBoxViews renders the newest boxes (already sorted newest-first by the
// service) into display rows, resolving owner labels.
func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxView {
if limit > 0 && len(boxes) > limit {
boxes = boxes[:limit]
}
cache := map[string]string{}
rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes {
rows = append(rows, adminBoxView{
ID: box.ID,
Owner: a.boxOwnerLabel(box.OwnerID, cache),
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04"),
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04"),
FileCount: box.FileCount,
TotalSizeLabel: box.TotalSizeLabel,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: box.Protected,
Expired: box.Expired,
})
}
return rows
}
// buildAdminOverview computes the last-14-day upload/storage series plus a few
// status distributions for the overview dashboard.
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
const days = 14
const chartMaxHeightPx = 150
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
counts := make([]int, days)
bytes := make([]int64, days)
labels := make([]string, days)
for i := 0; i < days; i++ {
day := today.AddDate(0, 0, -(days - 1 - i))
labels[i] = day.Format("Jan 2")
}
for _, box := range boxes {
created := box.CreatedAt.UTC()
day := time.Date(created.Year(), created.Month(), created.Day(), 0, 0, 0, 0, time.UTC)
offset := int(today.Sub(day).Hours() / 24)
idx := days - 1 - offset
if idx < 0 || idx >= days {
continue
}
counts[idx]++
bytes[idx] += box.TotalSize
}
maxCount := 0
var maxBytes int64
for i := 0; i < days; i++ {
if counts[i] > maxCount {
maxCount = counts[i]
}
if bytes[i] > maxBytes {
maxBytes = bytes[i]
}
}
uploadDays := make([]adminChartBar, days)
storageDays := make([]adminChartBar, days)
for i := 0; i < days; i++ {
uploadDays[i] = adminChartBar{
Label: labels[i],
Value: strconv.Itoa(counts[i]),
HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx),
RawValue: int64(counts[i]),
}
storageDays[i] = adminChartBar{
Label: labels[i],
Value: helpers.FormatBytes(bytes[i]),
HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx),
RawValue: bytes[i],
}
}
activeBoxes := stats.TotalBoxes - stats.ExpiredBoxes
if activeBoxes < 0 {
activeBoxes = 0
}
maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes)
statusBars := []adminStatBar{
{Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)},
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)},
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)},
}
return adminOverview{
UploadDays: uploadDays,
StorageDays: storageDays,
StatusBars: statusBars,
}
}
func scaleHeightPx(value, max int64, maxHeightPx int) int {
if max <= 0 || value <= 0 {
return 0
}
height := int(value * int64(maxHeightPx) / max)
if height < 8 {
height = 8
}
if height > maxHeightPx {
return maxHeightPx
}
return height
}
func percentOf(value, total int) int {
if total <= 0 || value <= 0 {
return 0
}
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
@@ -1174,38 +1346,6 @@ func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status in
}) })
} }
func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
boxes, err := a.uploadService.AdminBoxes(limit)
if err != nil {
return nil, err
}
rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes {
owner := "Anonymous"
if box.OwnerID != "" {
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
owner = user.Email
} else {
owner = "User"
}
}
rows = append(rows, adminBoxView{
ID: box.ID,
Owner: owner,
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2 15:04"),
FileCount: box.FileCount,
TotalSizeLabel: box.TotalSizeLabel,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: box.Protected,
Expired: box.Expired,
})
}
return rows, nil
}
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool { func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if a.isAdmin(r) { if a.isAdmin(r) {
return true return true
@@ -1465,21 +1605,111 @@ func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) {
} }
return left > right return left > right
}) })
if len(entries) > 500 {
entries = entries[:500] perPage := normalizePageSize(r.URL.Query().Get("per"), adminLogsDefaultPageSize, adminLogsPageSizes)
total := len(entries)
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
} }
page := 1
if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 {
page = parsed
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
rangeFrom := 0
if total > 0 {
rangeFrom = start + 1
}
state := adminLogsQuery{
Date: selectedDate,
Severity: severity,
Source: source,
Query: r.URL.Query().Get("q"),
Sort: sortOrder,
Per: perPage,
}
links := make([]adminFilesPageLink, 0, 5)
for p := page - 2; p <= page+2; p++ {
if p < 1 || p > totalPages {
continue
}
links = append(links, adminFilesPageLink{Page: p, Href: adminLogsHref(state, p), Active: p == page})
}
return adminLogsView{ return adminLogsView{
Entries: entries, Entries: entries[start:end],
Dates: dates, Dates: dates,
Date: selectedDate, Date: selectedDate,
Severity: severity, Severity: severity,
Source: source, Source: source,
Query: r.URL.Query().Get("q"), Query: r.URL.Query().Get("q"),
Sort: sortOrder, Sort: sortOrder,
TotalShown: len(entries), TotalShown: end - start,
Total: total,
Page: page,
PerPage: perPage,
PerPageOptions: adminLogsPageSizes,
TotalPages: totalPages,
RangeFrom: rangeFrom,
RangeTo: end,
PageLinks: links,
HasPrev: page > 1,
HasNext: page < totalPages,
PrevHref: adminLogsHref(state, page-1),
NextHref: adminLogsHref(state, page+1),
}, nil }, nil
} }
type adminLogsQuery struct {
Date string
Severity string
Source string
Query string
Sort string
Per int
}
func adminLogsHref(state adminLogsQuery, page int) string {
values := url.Values{}
if state.Date != "" {
values.Set("date", state.Date)
}
if state.Severity != "" {
values.Set("severity", state.Severity)
}
if state.Source != "" {
values.Set("source", state.Source)
}
if state.Query != "" {
values.Set("q", state.Query)
}
if state.Sort != "" && state.Sort != "desc" {
values.Set("sort", state.Sort)
}
if state.Per > 0 && state.Per != adminLogsDefaultPageSize {
values.Set("per", strconv.Itoa(state.Per))
}
if page > 1 {
values.Set("page", strconv.Itoa(page))
}
if len(values) == 0 {
return "/admin/logs"
}
return "/admin/logs?" + values.Encode()
}
func availableLogDates(logDir string) ([]string, error) { func availableLogDates(logDir string) ([]string, error) {
matches, err := filepath.Glob(filepath.Join(logDir, "*.log")) matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
if err != nil { if err != nil {
@@ -1520,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

@@ -14,7 +14,9 @@ import (
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
) )
const adminFilesPageSize = 25 const adminFilesDefaultPageSize = 50
var adminFilesPageSizes = []int{25, 50, 100, 200}
type adminFilesData struct { type adminFilesData struct {
Stats services.AdminStats Stats services.AdminStats
@@ -25,6 +27,8 @@ type adminFilesData struct {
Sort string Sort string
Dir string Dir string
Page int Page int
PerPage int
PerPageOptions []int
TotalPages int TotalPages int
Total int Total int
RangeFrom int RangeFrom int
@@ -37,6 +41,15 @@ type adminFilesData struct {
NextHref string NextHref string
} }
// adminFilesQuery captures the listing state that every paginated link must
// preserve.
type adminFilesQuery struct {
Query string
Sort string
Dir string
Per int
}
type adminFilesColumn struct { type adminFilesColumn struct {
Label string Label string
Href string Href string
@@ -153,8 +166,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
} }
sortAdminFileRows(rows, sortKey, dir) sortAdminFileRows(rows, sortKey, dir)
perPage := normalizePageSize(r.URL.Query().Get("per"), adminFilesDefaultPageSize, adminFilesPageSizes)
state := adminFilesQuery{Query: query, Sort: sortKey, Dir: dir, Per: perPage}
total := len(rows) total := len(rows)
totalPages := (total + adminFilesPageSize - 1) / adminFilesPageSize totalPages := (total + perPage - 1) / perPage
if totalPages < 1 { if totalPages < 1 {
totalPages = 1 totalPages = 1
} }
@@ -165,11 +181,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
if page > totalPages { if page > totalPages {
page = totalPages page = totalPages
} }
start := (page - 1) * adminFilesPageSize start := (page - 1) * perPage
if start > total { if start > total {
start = total start = total
} }
end := start + adminFilesPageSize end := start + perPage
if end > total { if end > total {
end = total end = total
} }
@@ -208,16 +224,18 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
Sort: sortKey, Sort: sortKey,
Dir: dir, Dir: dir,
Page: page, Page: page,
PerPage: perPage,
PerPageOptions: adminFilesPageSizes,
TotalPages: totalPages, TotalPages: totalPages,
Total: total, Total: total,
RangeFrom: rangeFrom, RangeFrom: rangeFrom,
RangeTo: end, RangeTo: end,
Columns: adminFilesColumns(query, sortKey, dir), Columns: adminFilesColumns(state, sortKey, dir),
PageLinks: adminFilesPageLinks(query, sortKey, dir, page, totalPages), PageLinks: adminFilesPageLinks(state, page, totalPages),
HasPrev: page > 1, HasPrev: page > 1,
HasNext: page < totalPages, HasNext: page < totalPages,
PrevHref: adminFilesHref(query, sortKey, dir, page-1), PrevHref: adminFilesHref(state, page-1),
NextHref: adminFilesHref(query, sortKey, dir, page+1), NextHref: adminFilesHref(state, page+1),
}, },
}) })
} }
@@ -274,7 +292,7 @@ func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) {
}) })
} }
func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn { func adminFilesColumns(state adminFilesQuery, sortKey, dir string) []adminFilesColumn {
defs := []struct{ Key, Label string }{ defs := []struct{ Key, Label string }{
{"id", "Box"}, {"id", "Box"},
{"owner", "Owner"}, {"owner", "Owner"},
@@ -291,9 +309,12 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
if sorted && dir == "asc" { if sorted && dir == "asc" {
nextDir = "desc" nextDir = "desc"
} }
colState := state
colState.Sort = def.Key
colState.Dir = nextDir
columns = append(columns, adminFilesColumn{ columns = append(columns, adminFilesColumn{
Label: def.Label, Label: def.Label,
Href: adminFilesHref(query, def.Key, nextDir, 1), Href: adminFilesHref(colState, 1),
Sorted: sorted, Sorted: sorted,
Ascending: dir == "asc", Ascending: dir == "asc",
}) })
@@ -301,7 +322,7 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
return columns return columns
} }
func adminFilesPageLinks(query, sortKey, dir string, page, totalPages int) []adminFilesPageLink { func adminFilesPageLinks(state adminFilesQuery, page, totalPages int) []adminFilesPageLink {
links := make([]adminFilesPageLink, 0, 5) links := make([]adminFilesPageLink, 0, 5)
const window = 2 const window = 2
for p := page - window; p <= page+window; p++ { for p := page - window; p <= page+window; p++ {
@@ -310,23 +331,26 @@ func adminFilesPageLinks(query, sortKey, dir string, page, totalPages int) []adm
} }
links = append(links, adminFilesPageLink{ links = append(links, adminFilesPageLink{
Page: p, Page: p,
Href: adminFilesHref(query, sortKey, dir, p), Href: adminFilesHref(state, p),
Active: p == page, Active: p == page,
}) })
} }
return links return links
} }
func adminFilesHref(query, sortKey, dir string, page int) string { func adminFilesHref(state adminFilesQuery, page int) string {
values := url.Values{} values := url.Values{}
if query != "" { if state.Query != "" {
values.Set("q", query) values.Set("q", state.Query)
} }
if sortKey != "" && sortKey != "created" { if state.Sort != "" && state.Sort != "created" {
values.Set("sort", sortKey) values.Set("sort", state.Sort)
} }
if dir != "" && dir != "desc" { if state.Dir != "" && state.Dir != "desc" {
values.Set("dir", dir) values.Set("dir", state.Dir)
}
if state.Per > 0 && state.Per != adminFilesDefaultPageSize {
values.Set("per", strconv.Itoa(state.Per))
} }
if page > 1 { if page > 1 {
values.Set("page", strconv.Itoa(page)) values.Set("page", strconv.Itoa(page))
@@ -337,6 +361,21 @@ func adminFilesHref(query, sortKey, dir string, page int) string {
return "/admin/files?" + values.Encode() return "/admin/files?" + values.Encode()
} }
// normalizePageSize parses a requested page size, falling back to def when the
// value is missing or not one of the allowed sizes.
func normalizePageSize(raw string, def int, allowed []int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return def
}
for _, size := range allowed {
if size == parsed {
return parsed
}
}
return def
}
func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) { func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) {
return return

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

@@ -16,12 +16,13 @@ type App struct {
uploadService *services.UploadService uploadService *services.UploadService
authService *services.AuthService authService *services.AuthService
settingsService *services.SettingsService settingsService *services.SettingsService
reactionService *services.ReactionService
banService *services.BanService banService *services.BanService
rateLimiter *rateLimiter rateLimiter *rateLimiter
uploadGroups *uploadGrouper uploadGroups *uploadGrouper
} }
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App { func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, reactionService *services.ReactionService, banService *services.BanService) *App {
return &App{ return &App{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
@@ -29,6 +30,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService, uploadService: uploadService,
authService: authService, authService: authService,
settingsService: settingsService, settingsService: settingsService,
reactionService: reactionService,
banService: banService, banService: banService,
rateLimiter: newRateLimiter(), rateLimiter: newRateLimiter(),
uploadGroups: newUploadGrouper(), uploadGroups: newUploadGrouper(),
@@ -121,6 +123,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox) mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox) mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip) mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
@@ -132,5 +135,6 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema) mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema)
mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema) mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema)
mux.HandleFunc("POST /api/v1/upload", a.Upload) mux.HandleFunc("POST /api/v1/upload", a.Upload)
mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset)
mux.Handle("GET /static/", a.Static()) mux.Handle("GET /static/", a.Static())
} }

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

@@ -2,12 +2,15 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -26,6 +29,7 @@ type downloadPageData struct {
DownloadCount int DownloadCount int
MaxDownloads int MaxDownloads int
ExpiresLabel string ExpiresLabel string
EmojiTabs []emojiTabView
} }
type boxView struct { type boxView struct {
@@ -41,6 +45,28 @@ type fileView struct {
URL string URL string
DownloadURL string DownloadURL string
ThumbnailURL string ThumbnailURL string
ReactURL string
Reactions []reactionView
Reacted bool
}
type reactionView struct {
EmojiID string `json:"emojiId"`
URL string `json:"url"`
Label string `json:"label"`
Count int `json:"count"`
}
type emojiTabView struct {
ID string
Label string
Emojis []emojiOptionView
}
type emojiOptionView struct {
ID string `json:"id"`
URL string `json:"url"`
Label string `json:"label"`
} }
type previewPageData struct { type previewPageData struct {
@@ -53,12 +79,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.",
@@ -70,13 +96,22 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return return
} }
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
visitorID := a.reactionVisitorID(w, r)
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
if err != nil {
a.logger.Warn("failed to load file reactions", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4300, "box_id", box.ID, "error", err.Error())...)
}
files := make([]fileView, 0, len(box.Files)) files := make([]fileView, 0, len(box.Files))
if !(locked && box.Obfuscate) { if !(locked && box.Obfuscate) {
for _, file := range box.Files { for _, file := range box.Files {
files = append(files, a.fileView(box, file)) files = append(files, a.fileViewWithReactions(box, file, reactionsByFile[file.ID], reactedByFile[file.ID]))
} }
} }
emojiTabs, err := a.emojiTabs()
if err != nil {
a.logger.Warn("failed to load emoji tabs", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4301, "box_id", box.ID, "error", err.Error())...)
}
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST") expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox" title := "Shared files on Warpbox"
@@ -99,9 +134,10 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
DownloadCount: box.DownloadCount, DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads, MaxDownloads: box.MaxDownloads,
ExpiresLabel: expiresLabel, ExpiresLabel: expiresLabel,
EmojiTabs: emojiTabs,
}, },
}) })
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 +175,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 +184,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 +238,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 +251,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 +280,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 +316,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,10 +342,14 @@ 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 {
return a.fileViewWithReactions(box, file, nil, false)
}
func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView {
return fileView{ return fileView{
ID: file.ID, ID: file.ID,
Name: file.Name, Name: file.Name,
@@ -319,9 +359,171 @@ func (a *App) fileView(box services.Box, file services.File) fileView {
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID), URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID), DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
Reactions: a.reactionViews(reactions),
Reacted: reacted,
} }
} }
func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid reaction", http.StatusBadRequest)
return
}
emojiID := strings.TrimSpace(r.FormValue("emoji_id"))
if !a.validEmojiID(emojiID) {
http.Error(w, "unknown emoji", http.StatusBadRequest)
return
}
visitorID := a.reactionVisitorID(w, r)
reactions, err := a.reactionService.Add(box.ID, file.ID, visitorID, emojiID)
if errors.Is(err, os.ErrExist) {
writeJSON(w, http.StatusConflict, map[string]any{"error": "already reacted"})
return
}
if err != nil {
a.logger.Warn("file reaction failed", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4302, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
http.Error(w, "could not save reaction", http.StatusInternalServerError)
return
}
a.logger.Info("file reaction added", withRequestLogAttrs(r, "source", "reactions", "severity", "user_activity", "code", 2301, "box_id", box.ID, "file_id", file.ID, "emoji_id", emojiID)...)
writeJSON(w, http.StatusCreated, map[string]any{
"reactions": a.reactionViews(reactions),
"reacted": true,
})
}
func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
views := make([]reactionView, 0, len(reactions))
for _, reaction := range reactions {
views = append(views, reactionView{
EmojiID: reaction.EmojiID,
URL: emojiURL(reaction.EmojiID),
Label: emojiLabel(reaction.EmojiID),
Count: reaction.Count,
})
}
return views
}
func (a *App) emojiTabs() ([]emojiTabView, error) {
root := a.emojiRoot()
entries, err := os.ReadDir(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
tabs := make([]emojiTabView, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
tabID := entry.Name()
files, err := os.ReadDir(filepath.Join(root, tabID))
if err != nil {
return nil, err
}
tab := emojiTabView{ID: tabID, Label: emojiTabLabel(tabID)}
for _, file := range files {
if file.IsDir() || !isEmojiFile(file.Name()) {
continue
}
emojiID := tabID + "/" + file.Name()
tab.Emojis = append(tab.Emojis, emojiOptionView{
ID: emojiID,
URL: emojiURL(emojiID),
Label: emojiLabel(emojiID),
})
}
sort.Slice(tab.Emojis, func(i, j int) bool { return tab.Emojis[i].ID < tab.Emojis[j].ID })
if len(tab.Emojis) > 0 {
tabs = append(tabs, tab)
}
}
sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
return tabs, nil
}
func (a *App) validEmojiID(id string) bool {
id = strings.TrimSpace(id)
if id == "" || strings.Contains(id, "\\") || strings.Contains(id, "..") || strings.HasPrefix(id, "/") {
return false
}
parts := strings.Split(id, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !isEmojiFile(parts[1]) {
return false
}
info, err := os.Stat(filepath.Join(a.emojiRoot(), parts[0], parts[1]))
return err == nil && !info.IsDir()
}
func (a *App) emojiRoot() string {
return filepath.Join(a.cfg.DataDir, "emoji")
}
func (a *App) reactionVisitorID(w http.ResponseWriter, r *http.Request) string {
const cookieName = "warpbox_reactor"
if cookie, err := r.Cookie(cookieName); err == nil && strings.TrimSpace(cookie.Value) != "" {
return cookie.Value
}
visitorID := services.RandomPublicToken(32)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: visitorID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().AddDate(1, 0, 0),
})
return visitorID
}
func isEmojiFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
}
func emojiTabLabel(id string) string {
label := strings.NewReplacer("-", " ", "_", " ").Replace(id)
if label == "" {
return "Emoji"
}
return strings.ToUpper(label[:1]) + label[1:]
}
func emojiLabel(id string) string {
base := strings.TrimSuffix(filepath.Base(id), filepath.Ext(id))
return strings.ReplaceAll(base, "-", " ")
}
func emojiURL(id string) string {
parts := strings.Split(id, "/")
if len(parts) != 2 {
return ""
}
return "/emoji/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1])
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool { func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
if !a.uploadService.IsProtected(box) { if !a.uploadService.IsProtected(box) {
return true return true

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

@@ -2,6 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
@@ -15,6 +16,24 @@ func (a *App) Static() http.Handler {
}) })
} }
func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
pack := strings.TrimSpace(r.PathValue("pack"))
file := strings.TrimSpace(r.PathValue("file"))
if pack == "" || file == "" || strings.Contains(pack, "/") || strings.Contains(pack, "\\") || strings.Contains(pack, "..") || strings.Contains(file, "/") || strings.Contains(file, "\\") || strings.Contains(file, "..") || !isEmojiFile(file) {
http.NotFound(w, r)
return
}
path := filepath.Join(a.emojiRoot(), pack, file)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
setStaticCacheHeaders(w, r.URL.Path)
http.ServeFile(w, r, path)
}
func setStaticCacheHeaders(w http.ResponseWriter, path string) { func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))

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

@@ -46,6 +46,42 @@ func TestUploadJSONIncludesManageURLsAndAcceptsShareXField(t *testing.T) {
} }
} }
func TestFileReactionCanBeAddedOncePerVisitor(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
if len(payload.Files) != 1 {
t.Fatalf("uploaded files = %d", len(payload.Files))
}
request := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.ReactToFile(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("first reaction status = %d, body = %s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"count":1`) {
t.Fatalf("reaction response missing count: %s", response.Body.String())
}
retry := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
retry.Header.Set("Content-Type", "application/x-www-form-urlencoded")
retry.SetPathValue("boxID", payload.BoxID)
retry.SetPathValue("fileID", payload.Files[0].ID)
for _, cookie := range response.Result().Cookies() {
retry.AddCookie(cookie)
}
retryResponse := httptest.NewRecorder()
app.ReactToFile(retryResponse, retry)
if retryResponse.Code != http.StatusConflict {
t.Fatalf("second reaction status = %d, body = %s", retryResponse.Code, retryResponse.Body.String())
}
}
func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) { func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -198,6 +234,14 @@ func newTestApp(t *testing.T) (*App, func()) {
if err != nil { if err != nil {
t.Fatalf("NewUploadService returned error: %v", err) t.Fatalf("NewUploadService returned error: %v", err)
} }
if err := os.MkdirAll(filepath.Join(cfg.DataDir, "emoji", "openmoji"), 0o755); err != nil {
service.Close()
t.Fatalf("create emoji test dir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.DataDir, "emoji", "openmoji", "1F600.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>`), 0o644); err != nil {
service.Close()
t.Fatalf("write emoji test file: %v", err)
}
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL) renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil { if err != nil {
service.Close() service.Close()
@@ -213,12 +257,17 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close() service.Close()
t.Fatalf("NewSettingsService returned error: %v", err) t.Fatalf("NewSettingsService returned error: %v", err)
} }
reactionService, err := services.NewReactionService(service.DB())
if err != nil {
service.Close()
t.Fatalf("NewReactionService returned error: %v", err)
}
banService, err := services.NewBanService(service.DB()) banService, err := services.NewBanService(service.DB())
if err != nil { if err != nil {
service.Close() service.Close()
t.Fatalf("NewBanService returned error: %v", err) t.Fatalf("NewBanService returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() { return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
if err := service.Close(); err != nil { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }

View File

@@ -32,13 +32,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
uploadService.Close() uploadService.Close()
return nil, err return nil, err
} }
reactionService, err := services.NewReactionService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
banService, err := services.NewBanService(uploadService.DB()) banService, err := services.NewBanService(uploadService.DB())
if err != nil { if err != nil {
uploadService.Close() uploadService.Close()
return nil, err return nil, err
} }
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService) stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService) app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, reactionService, banService)
router := http.NewServeMux() router := http.NewServeMux()
app.RegisterRoutes(router) app.RegisterRoutes(router)
@@ -50,13 +55,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,

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

@@ -0,0 +1,166 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
)
var reactionsBucket = []byte("file_reactions")
type ReactionService struct {
db *bbolt.DB
}
type FileReaction struct {
BoxID string `json:"boxId"`
FileID string `json:"fileId"`
EmojiID string `json:"emojiId"`
VisitorHash string `json:"visitorHash"`
CreatedAt time.Time `json:"createdAt"`
}
type ReactionSummary struct {
EmojiID string `json:"emojiId"`
Count int `json:"count"`
}
func NewReactionService(db *bbolt.DB) (*ReactionService, error) {
if err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(reactionsBucket)
return err
}); err != nil {
return nil, err
}
return &ReactionService{db: db}, nil
}
func (s *ReactionService) Add(boxID, fileID, visitorID, emojiID string) ([]ReactionSummary, error) {
boxID = strings.TrimSpace(boxID)
fileID = strings.TrimSpace(fileID)
visitorHash := reactionVisitorHash(visitorID)
emojiID = strings.TrimSpace(emojiID)
if boxID == "" || fileID == "" || visitorHash == "" || emojiID == "" {
return nil, errors.New("missing reaction data")
}
reaction := FileReaction{
BoxID: boxID,
FileID: fileID,
EmojiID: emojiID,
VisitorHash: visitorHash,
CreatedAt: time.Now().UTC(),
}
data, err := json.Marshal(reaction)
if err != nil {
return nil, err
}
key := reactionKey(boxID, fileID, visitorHash)
if err := s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket.Get([]byte(key)) != nil {
return os.ErrExist
}
return bucket.Put([]byte(key), data)
}); err != nil {
return nil, err
}
return s.SummaryForFile(boxID, fileID)
}
func (s *ReactionService) SummaryForBox(boxID, visitorID string) (map[string][]ReactionSummary, map[string]bool, error) {
visitorHash := reactionVisitorHash(visitorID)
summaries := make(map[string]map[string]int)
viewerReacted := make(map[string]bool)
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, data []byte) error {
var reaction FileReaction
if err := json.Unmarshal(data, &reaction); err != nil {
return err
}
if reaction.BoxID != boxID {
return nil
}
if summaries[reaction.FileID] == nil {
summaries[reaction.FileID] = make(map[string]int)
}
summaries[reaction.FileID][reaction.EmojiID]++
if visitorHash != "" && reaction.VisitorHash == visitorHash {
viewerReacted[reaction.FileID] = true
}
return nil
})
})
if err != nil {
return nil, nil, err
}
result := make(map[string][]ReactionSummary, len(summaries))
for fileID, counts := range summaries {
result[fileID] = reactionCountsToSummaries(counts)
}
return result, viewerReacted, nil
}
func (s *ReactionService) SummaryForFile(boxID, fileID string) ([]ReactionSummary, error) {
counts := make(map[string]int)
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, data []byte) error {
var reaction FileReaction
if err := json.Unmarshal(data, &reaction); err != nil {
return err
}
if reaction.BoxID == boxID && reaction.FileID == fileID {
counts[reaction.EmojiID]++
}
return nil
})
})
if err != nil {
return nil, err
}
return reactionCountsToSummaries(counts), nil
}
func reactionCountsToSummaries(counts map[string]int) []ReactionSummary {
summaries := make([]ReactionSummary, 0, len(counts))
for emojiID, count := range counts {
summaries = append(summaries, ReactionSummary{EmojiID: emojiID, Count: count})
}
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].Count == summaries[j].Count {
return summaries[i].EmojiID < summaries[j].EmojiID
}
return summaries[i].Count > summaries[j].Count
})
return summaries
}
func reactionKey(boxID, fileID, visitorHash string) string {
return boxID + "\x00" + fileID + "\x00" + visitorHash
}
func reactionVisitorHash(visitorID string) string {
visitorID = strings.TrimSpace(visitorID)
if visitorID == "" {
return ""
}
sum := sha256.Sum256([]byte(visitorID))
return hex.EncodeToString(sum[:])
}

View File

@@ -137,6 +137,9 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
if err := os.MkdirAll(dbDir, 0o755); err != nil { if err := os.MkdirAll(dbDir, 0o755); err != nil {
return nil, err return nil, err
} }
if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
return nil, err
}
db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second}) db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
if err != nil { if err != nil {
@@ -957,6 +960,10 @@ func randomID(byteCount int) string {
return base64.RawURLEncoding.EncodeToString(data) return base64.RawURLEncoding.EncodeToString(data)
} }
func RandomPublicToken(byteCount int) string {
return randomID(byteCount)
}
func hashPassword(password string) (string, string) { func hashPassword(password string) (string, string) {
salt := randomID(18) salt := randomID(18)
return salt, passwordHash(salt, password) return salt, passwordHash(salt, password)

View File

@@ -65,6 +65,242 @@
.file-card { .file-card {
position: relative; position: relative;
padding-bottom: 2.6rem;
}
.file-reaction-dock {
position: absolute;
right: 0.65rem;
bottom: 0.55rem;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: flex-end;
max-width: calc(100% - 1.3rem);
gap: 0.35rem;
pointer-events: none;
}
.file-reactions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
gap: 0.25rem;
flex-wrap: wrap;
}
.reaction-pill {
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-height: 1.6rem;
padding: 0.16rem 0.38rem;
border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary));
border-radius: 999px;
background: color-mix(in srgb, var(--card) 88%, #000);
color: var(--foreground);
font-size: 0.75rem;
font-weight: 700;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24);
pointer-events: auto;
}
.reaction-pill img {
width: 1rem;
height: 1rem;
display: block;
}
.reaction-button {
width: 2.1rem;
height: 2.1rem;
display: inline-grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 999px;
background: color-mix(in srgb, var(--card) 92%, #000);
color: var(--foreground);
opacity: 0;
transform: translateY(0.3rem) scale(0.94);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
transition: opacity 150ms ease, transform 150ms ease, border-color 150ms ease, background 150ms ease;
pointer-events: auto;
}
.reaction-button svg {
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.file-card:hover .reaction-button,
.file-card:focus-within .reaction-button,
.reaction-button:focus-visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.reaction-button:hover,
.reaction-button:focus-visible {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-picker {
position: fixed;
top: 0;
left: 0;
z-index: 70;
width: min(21rem, calc(100vw - 1rem));
}
html.reaction-picker-open,
html.reaction-picker-open body {
overflow: hidden;
touch-action: none;
}
.reaction-picker[hidden] {
display: none;
}
.reaction-picker.is-mobile {
inset: 0;
width: auto;
height: 100dvh;
display: grid;
place-items: end center;
overflow: hidden;
padding: 0.75rem 0.75rem max(1.5rem, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.54);
}
.reaction-picker-panel {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 97%, #000);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.52);
}
.reaction-picker.is-mobile .reaction-picker-panel {
width: min(100%, 34rem);
height: 75dvh;
max-height: 75dvh;
display: flex;
flex-direction: column;
}
.reaction-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem;
border-bottom: 1px solid var(--border);
}
.reaction-picker-close {
min-height: 2rem;
padding: 0.3rem 0.55rem;
font-size: 0.75rem;
}
.reaction-picker-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
padding: 0.55rem 0.7rem 0;
}
.reaction-tab {
flex: 0 0 auto;
min-height: 1.8rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
font-size: 0.75rem;
font-weight: 700;
}
.reaction-tab.is-active {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-search {
display: block;
padding: 0.55rem 0.7rem;
}
.reaction-search input {
width: 100%;
min-height: 2.15rem;
padding: 0.35rem 0.55rem;
}
.reaction-grid-wrap {
max-height: 18rem;
overflow: auto;
padding: 0 0.7rem 0.7rem;
}
.reaction-picker.is-mobile .reaction-grid-wrap {
max-height: none;
flex: 1;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.reaction-grid {
display: none;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 0.25rem;
}
.reaction-grid.is-active {
display: grid;
}
.reaction-picker.is-mobile .reaction-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.reaction-emoji {
aspect-ratio: 1;
display: grid;
place-items: center;
min-width: 0;
padding: 0.18rem;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 0.25rem);
background: transparent;
}
.reaction-emoji:hover,
.reaction-emoji:focus-visible {
border-color: var(--border);
background: var(--accent);
}
.reaction-emoji[hidden] {
display: none;
}
.reaction-emoji img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
} }
.thumb-link { .thumb-link {

View File

@@ -62,7 +62,8 @@
white-space: nowrap; white-space: nowrap;
} }
.user-edit-metrics { .user-edit-metrics,
.metric-grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@@ -138,6 +139,172 @@
font-size: 0.78rem; font-size: 0.78rem;
} }
.pagination-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1rem;
}
.pagination-bar .pagination {
margin-top: 0;
}
.per-page-control {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.per-page-control select {
width: auto;
min-width: 4.5rem;
min-height: 2rem;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.button.is-disabled {
pointer-events: none;
opacity: 0.45;
}
/* Overview charts */
.admin-charts {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
.chart-card {
min-width: 0;
}
.chart-card h2 {
margin: 0;
font-size: 1.05rem;
}
.chart-card .muted-copy {
margin: 0.3rem 0 0;
}
.bar-chart {
display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr));
align-items: end;
gap: 0.4rem;
min-height: 13rem;
margin-top: 1.25rem;
padding-top: 0.5rem;
}
.bar-chart-col {
display: flex;
flex-direction: column;
min-width: 0;
align-items: stretch;
gap: 0.35rem;
}
.bar-chart-track {
display: flex;
align-items: flex-end;
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 {
display: block;
width: 100%;
min-height: 0;
border-radius: 6px 6px 0 0;
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 {
min-height: 1rem;
overflow: hidden;
color: var(--foreground);
font-size: 0.72rem;
font-weight: 650;
line-height: 1;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-chart-label {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.66rem;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-bars {
display: grid;
gap: 0.9rem;
margin-top: 1.25rem;
}
.stat-bar span {
display: flex;
justify-content: space-between;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.stat-bar span strong {
color: var(--foreground);
}
.stat-bar-track {
display: block;
width: 100%;
margin-top: 0.35rem;
height: 0.55rem;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.stat-bar-fill {
display: block;
height: 100%;
min-width: 0;
border-radius: 999px;
background: var(--primary, #8b5cf6);
}
@media (max-width: 900px) {
.admin-charts {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.metric-grid-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.table-actions { .table-actions {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@@ -220,6 +220,16 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.file-reaction-dock {
right: 0.5rem;
bottom: 0.45rem;
}
.reaction-button {
opacity: 1;
transform: none;
}
.file-progress-side { .file-progress-side {
width: 100%; width: 100%;
} }

View File

@@ -0,0 +1,198 @@
(function () {
const picker = document.querySelector("[data-reaction-picker]");
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
let activeButton = null;
let activeCard = null;
document.querySelectorAll("[data-reaction-button]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
openPicker(button);
});
});
if (!picker || !panel) {
return;
}
// Aurora's glass card uses backdrop-filter, and the main content animates
// with transform. Both can create a containing block for fixed descendants,
// so keep the floating picker at body level where viewport coordinates mean
// what they say.
document.body.appendChild(picker);
picker.addEventListener("click", (event) => {
if (event.target === picker) {
closePicker();
}
});
panel.addEventListener("click", async (event) => {
const emoji = event.target.closest("[data-emoji-id]");
if (!emoji || !activeButton || !activeCard) {
return;
}
await submitReaction(emoji);
});
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
setActiveTab(tab.dataset.reactionTab);
});
});
if (search) {
search.addEventListener("input", () => filterEmoji(search.value));
}
if (closeButton) {
closeButton.addEventListener("click", closePicker);
}
document.addEventListener("click", (event) => {
if (picker.hidden) {
return;
}
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
return;
}
closePicker();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closePicker();
}
});
window.addEventListener("resize", () => {
if (activeButton && !picker.hidden) {
positionPicker(activeButton);
}
});
function openPicker(button) {
activeButton = button;
activeCard = button.closest("[data-reaction-card]");
picker.hidden = false;
picker.classList.add("is-open");
if (search) {
search.value = "";
filterEmoji("");
}
positionPicker(button);
}
function closePicker() {
picker.hidden = true;
picker.classList.remove("is-open", "is-mobile");
document.documentElement.classList.remove("reaction-picker-open");
picker.style.left = "";
picker.style.top = "";
activeButton = null;
activeCard = null;
}
function positionPicker(button) {
if (isMobilePicker()) {
picker.classList.add("is-mobile");
document.documentElement.classList.add("reaction-picker-open");
picker.style.left = "0px";
picker.style.top = "0px";
return;
}
picker.classList.remove("is-mobile");
document.documentElement.classList.remove("reaction-picker-open");
picker.style.left = "0px";
picker.style.top = "0px";
const buttonRect = button.getBoundingClientRect();
const pickerRect = panel.getBoundingClientRect();
const margin = 10;
const preferredLeft = buttonRect.left + (buttonRect.width / 2) - (pickerRect.width / 2);
const preferredTop = buttonRect.bottom + 8;
const left = Math.min(Math.max(margin, preferredLeft), window.innerWidth - pickerRect.width - margin);
const top = Math.min(Math.max(margin, preferredTop), window.innerHeight - pickerRect.height - margin);
picker.style.left = `${left}px`;
picker.style.top = `${top}px`;
}
function isMobilePicker() {
return window.matchMedia("(max-width: 820px), (pointer: coarse)").matches;
}
function setActiveTab(tabID) {
tabs.forEach((tab) => {
const active = tab.dataset.reactionTab === tabID;
tab.classList.toggle("is-active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
});
panels.forEach((item) => {
item.classList.toggle("is-active", item.dataset.reactionPanel === tabID);
});
}
function filterEmoji(value) {
const query = value.trim().toLowerCase();
picker.querySelectorAll("[data-emoji-id]").forEach((button) => {
const haystack = `${button.dataset.emojiId} ${button.dataset.emojiLabel}`.toLowerCase();
button.hidden = query !== "" && !haystack.includes(query);
});
}
async function submitReaction(emoji) {
const body = new URLSearchParams();
body.set("emoji_id", emoji.dataset.emojiId);
activeButton.disabled = true;
const response = await fetch(activeButton.dataset.reactUrl, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
activeButton.disabled = false;
closePicker();
return;
}
const payload = await response.json();
renderReactions(activeCard, payload.reactions || []);
activeButton.remove();
closePicker();
}
function renderReactions(card, reactions) {
const list = card.querySelector("[data-reaction-list]");
if (!list) {
return;
}
list.replaceChildren();
reactions.forEach((reaction) => {
const pill = document.createElement("span");
pill.className = "reaction-pill";
pill.title = reaction.label || reaction.emojiId;
const image = document.createElement("img");
image.src = reaction.url;
image.alt = reaction.label || reaction.emojiId;
image.loading = "lazy";
const count = document.createElement("span");
count.textContent = reaction.count;
pill.append(image, count);
list.append(pill);
});
}
})();

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

@@ -0,0 +1,43 @@
// Per-page selector: remembers the chosen page size in localStorage and keeps
// the URL's `per` query param in sync. CSP-safe (external file, no inline JS).
(function () {
const select = document.querySelector("[data-per-page]");
if (!select) {
return;
}
const key = "warpbox-perpage-" + select.dataset.perPage;
const url = new URL(window.location.href);
const current = url.searchParams.get("per");
let stored = null;
try {
stored = window.localStorage.getItem(key);
} catch (err) {
stored = null;
}
// No explicit choice in the URL but a remembered preference exists: apply it.
if (!current && stored && stored !== select.value) {
const valid = Array.prototype.some.call(select.options, function (opt) {
return opt.value === stored;
});
if (valid) {
url.searchParams.set("per", stored);
url.searchParams.delete("page");
window.location.replace(url.toString());
return;
}
}
select.addEventListener("change", function () {
try {
window.localStorage.setItem(key, select.value);
} catch (err) {
/* ignore storage failures (private mode, etc.) */
}
const next = new URL(window.location.href);
next.searchParams.set("per", select.value);
next.searchParams.delete("page");
window.location.assign(next.toString());
});
})();

View File

@@ -31,8 +31,11 @@
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
<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/12-reactions.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/40-upload.js?version={{.AppVersion}}"></script> <script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
</head> </head>
<body class="dark"> <body class="dark">

View File

@@ -58,6 +58,55 @@
</article> </article>
</div> </div>
<div class="admin-charts">
<div class="card chart-card">
<div class="card-content">
<h2>Uploads per day</h2>
<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">
{{range .Data.Overview.UploadDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</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>
</div>
{{end}}
</div>
</div>
</div>
<div class="card chart-card">
<div class="card-content">
<h2>Box status</h2>
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
<div class="stat-bars">
{{range .Data.Overview.StatusBars}}
<div class="stat-bar" data-stat-value="{{.RawValue}}">
<span>{{.Label}} <strong>{{.Value}}</strong></span>
<span class="stat-bar-track"><span class="stat-bar-fill" data-width-percent="{{.WidthPercent}}" style="width: {{.WidthPercent}}%"></span></span>
</div>
{{end}}
</div>
</div>
</div>
</div>
<div class="card chart-card">
<div class="card-content">
<h2>Storage added per day</h2>
<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">
{{range .Data.Overview.StorageDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</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>
</div>
{{end}}
</div>
</div>
</div>
<div class="card admin-table-card"> <div class="card admin-table-card">
<div class="card-content"> <div class="card-content">
<div class="table-header"> <div class="table-header">

View File

@@ -34,7 +34,7 @@
{{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}} {{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}}
{{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}} {{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}}
<div class="metric-grid"> <div class="metric-grid metric-grid-4">
<article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article> <article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article>
<article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article> <article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article> <article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>

View File

@@ -42,6 +42,7 @@
<form class="inline-controls" method="get" action="/admin/files"> <form class="inline-controls" method="get" action="/admin/files">
<input type="hidden" name="sort" value="{{.Data.Sort}}"> <input type="hidden" name="sort" value="{{.Data.Sort}}">
<input type="hidden" name="dir" value="{{.Data.Dir}}"> <input type="hidden" name="dir" value="{{.Data.Dir}}">
<input type="hidden" name="per" value="{{.Data.PerPage}}">
<label> <label>
<span class="sr-only">Search</span> <span class="sr-only">Search</span>
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner"> <input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
@@ -92,13 +93,19 @@
</table> </table>
</div> </div>
{{if gt .Data.TotalPages 1}} <div class="pagination-bar">
<nav class="pagination" aria-label="Pagination"> <nav class="pagination" aria-label="Pagination">
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{end}} {{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}} {{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{end}} {{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
</nav> </nav>
{{end}} <label class="per-page-control">
<span>Per page</span>
<select data-per-page="files" aria-label="Items per page">
{{range .Data.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.PerPage}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
</div>
<p class="pagination-summary">Showing {{.Data.RangeFrom}}{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p> <p class="pagination-summary">Showing {{.Data.RangeFrom}}{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
</div> </div>
</div> </div>

View File

@@ -54,6 +54,7 @@
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option> <option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
</select> </select>
</label> </label>
<input type="hidden" name="per" value="{{.Data.Logs.PerPage}}">
<button class="button button-primary" type="submit">Filter</button> <button class="button button-primary" type="submit">Filter</button>
</form> </form>
@@ -62,7 +63,7 @@
<div class="table-header"> <div class="table-header">
<div> <div>
<h2>Log entries</h2> <h2>Log entries</h2>
<p>Showing up to 500 entries. {{.Data.Logs.TotalShown}} currently visible.</p> <p>{{.Data.Logs.Total}} entries match these filters.</p>
</div> </div>
</div> </div>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
@@ -98,6 +99,21 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination-bar">
<nav class="pagination" aria-label="Pagination">
{{if .Data.Logs.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.Logs.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
{{range .Data.Logs.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
{{if .Data.Logs.HasNext}}<a class="button button-outline button-sm" href="{{.Data.Logs.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
</nav>
<label class="per-page-control">
<span>Per page</span>
<select data-per-page="logs" aria-label="Items per page">
{{range .Data.Logs.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.Logs.PerPage}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
</div>
<p class="pagination-summary">Showing {{.Data.Logs.RangeFrom}}{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -44,7 +44,7 @@
<div class="download-list file-browser is-list" data-file-browser> <div class="download-list file-browser is-list" data-file-browser>
{{range .Data.Files}} {{range .Data.Files}}
<article class="download-item file-card" data-kind="{{.PreviewKind}}" data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}" data-file-name="{{.Name}}"> <article class="download-item file-card" data-kind="{{.PreviewKind}}" data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}" data-file-name="{{.Name}}" data-reaction-card>
<a class="thumb-link" href="{{.DownloadURL}}?inline=1" aria-label="View {{.Name}}"> <a class="thumb-link" href="{{.DownloadURL}}?inline=1" aria-label="View {{.Name}}">
<img src="{{.ThumbnailURL}}" alt="" loading="lazy"> <img src="{{.ThumbnailURL}}" alt="" loading="lazy">
</a> </a>
@@ -64,11 +64,54 @@
Download Download
</a> </a>
</div> </div>
<div class="file-reaction-dock" data-reaction-dock>
<div class="file-reactions" data-reaction-list>
{{range .Reactions}}
<span class="reaction-pill" title="{{.Label}}">
<img src="{{.URL}}" alt="{{.Label}}" loading="lazy">
<span>{{.Count}}</span>
</span>
{{end}}
</div>
{{if not .Reacted}}
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 21a9 9 0 1 0-9-9 9 9 0 0 0 9 9Z" /><path d="M8 14s1.4 2 4 2 4-2 4-2" /><path d="M9 9h.01M15 9h.01" /></svg>
</button>
{{end}}
</div>
{{end}} {{end}}
</article> </article>
{{end}} {{end}}
</div> </div>
{{if not .Data.Locked}} {{if not .Data.Locked}}
<div class="reaction-picker" data-reaction-picker hidden>
<div class="reaction-picker-panel" role="dialog" aria-modal="false" aria-label="Choose a reaction">
<div class="reaction-picker-head">
<strong>React</strong>
<button class="button button-ghost reaction-picker-close" type="button" data-reaction-close aria-label="Close reaction picker">Close</button>
</div>
<div class="reaction-picker-tabs" role="tablist" aria-label="Emoji themes">
{{range $index, $tab := .Data.EmojiTabs}}
<button type="button" class="reaction-tab {{if eq $index 0}}is-active{{end}}" data-reaction-tab="{{$tab.ID}}" role="tab" aria-selected="{{if eq $index 0}}true{{else}}false{{end}}">{{$tab.Label}}</button>
{{end}}
</div>
<label class="reaction-search">
<span class="sr-only">Search emoji</span>
<input type="search" data-reaction-search placeholder="Search emoji">
</label>
<div class="reaction-grid-wrap">
{{range $index, $tab := .Data.EmojiTabs}}
<div class="reaction-grid {{if eq $index 0}}is-active{{end}}" data-reaction-panel="{{$tab.ID}}" role="tabpanel">
{{range $tab.Emojis}}
<button class="reaction-emoji" type="button" data-emoji-id="{{.ID}}" data-emoji-label="{{.Label}}" title="{{.Label}}" aria-label="{{.Label}}">
<img src="{{.URL}}" alt="" loading="lazy">
</button>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
<div class="context-menu" data-file-context-menu role="menu" aria-label="File actions" hidden> <div class="context-menu" data-file-context-menu role="menu" aria-label="File actions" hidden>
<div class="context-menu-top"> <div class="context-menu-top">
<small>File actions</small> <small>File actions</small>

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=