8 Commits

Author SHA1 Message Date
6c87187c6d refactor(api): consolidate health check endpoints to /health
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
Removes the redundant `/healthz` and `/api/v1/health` endpoints, leaving `/health` as the sole health check endpoint.

- Update router to return 404 Not Found for the removed endpoints
- Update admin log filtering to only ignore `/health`
- Remove health URL from API documentation data
- Update tests to verify `/health` returns 200 and others return 404
- Update README documentation to reflect the change
2026-06-02 11:54:38 +03:00
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
35 changed files with 1463 additions and 238 deletions

View File

@@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
WARPBOX_IDLE_TIMEOUT=120s
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.
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
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`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
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:
```bash
@@ -72,8 +108,7 @@ The compose example also works with Podman compatible compose tools. Its data vo
`./data:/data:Z` for SELinux relabeling, and the container overrides runtime paths to use
`/data`, `/app/static`, and `/app/templates`.
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks
use `/health`.
The image exposes the health endpoint: `/health`. Docker and compose healthchecks use it.
## Reverse Proxy Security
@@ -106,6 +141,9 @@ WARPBOX_DATA_DIR=/var/lib/warpbox
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
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`:

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
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
Active bans return:

View File

@@ -20,6 +20,7 @@ type Config struct {
AdminToken string
StaticDir string
TemplateDir string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
@@ -64,8 +65,9 @@ func Load() (Config, error) {
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),

View File

@@ -1,6 +1,9 @@
package config
import "testing"
import (
"testing"
"time"
)
func TestParseMegabytes(t *testing.T) {
tests := map[string]int64{
@@ -49,3 +52,20 @@ func TestEnvBool(t *testing.T) {
t.Fatalf("envBool() did not fall back to true")
}
}
func TestLoadDefaultsUseLargeUploadFriendlyTimeouts(t *testing.T) {
t.Setenv("WARPBOX_BASE_URL", "http://example.test")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.ReadHeaderTimeout != 15*time.Second {
t.Fatalf("ReadHeaderTimeout = %s, want 15s", cfg.ReadHeaderTimeout)
}
if cfg.ReadTimeout != 0 {
t.Fatalf("ReadTimeout = %s, want 0 for long uploads", cfg.ReadTimeout)
}
if cfg.WriteTimeout != 0 {
t.Fatalf("WriteTimeout = %s, want 0 for long uploads", cfg.WriteTimeout)
}
}

View File

@@ -707,21 +707,94 @@ func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
for i, bar := range overview.UploadDays {
want := 0
if i == len(overview.UploadDays)-1 {
want = 100
want = 150
}
if bar.Height != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want)
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 = 100
want = 150
}
if bar.Height != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want)
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) {
@@ -1016,6 +1089,7 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
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: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")
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
@@ -1036,6 +1110,16 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
if strings.Contains(logsBody, "172.30.0.1:48358") {
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.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})

View File

@@ -161,13 +161,15 @@ type adminOverview struct {
type adminChartBar struct {
Label string
Value string
Height int // 0-100, percent of the tallest bar
HeightPx int
RawValue int64
}
type adminStatBar struct {
Label string
Value string
Percent int
RawValue int
WidthPercent int
}
type adminBoxView struct {
@@ -336,6 +338,7 @@ func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxVie
// 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)
@@ -376,12 +379,14 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
uploadDays[i] = adminChartBar{
Label: labels[i],
Value: strconv.Itoa(counts[i]),
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx),
RawValue: int64(counts[i]),
}
storageDays[i] = adminChartBar{
Label: labels[i],
Value: helpers.FormatBytes(bytes[i]),
Height: scaleHeight(bytes[i], maxBytes),
HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx),
RawValue: bytes[i],
}
}
@@ -389,10 +394,11 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
if activeBoxes < 0 {
activeBoxes = 0
}
maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes)
statusBars := []adminStatBar{
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)},
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)},
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)},
{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{
@@ -402,13 +408,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
}
}
func scaleHeight(value, max int64) int {
func scaleHeightPx(value, max int64, maxHeightPx int) int {
if max <= 0 || value <= 0 {
return 0
}
height := int(value * 100 / max)
if height < 4 {
height = 4
height := int(value * int64(maxHeightPx) / max)
if height < 8 {
height = 8
}
if height > maxHeightPx {
return maxHeightPx
}
return height
}
@@ -420,6 +429,16 @@ func percentOf(value, total int) int {
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) {
if !a.requireAdmin(w, r) {
return
@@ -1731,11 +1750,29 @@ func readLogEntries(file string) ([]adminLogEntry, error) {
if err := json.Unmarshal(line, &raw); err != nil {
continue
}
if isHealthCheckLogEntry(raw) {
continue
}
entries = append(entries, logEntryFromMap(raw))
}
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"
}
func logEntryFromMap(raw map[string]any) adminLogEntry {
entry := adminLogEntry{
Date: logString(raw, "date"),

View File

@@ -10,7 +10,6 @@ import (
type apiDocsData struct {
BaseURL string
UploadURL string
HealthURL string
RequestSchemaURL string
ResponseSchemaURL string
ShareXExamplePath string
@@ -21,13 +20,24 @@ type apiDocsData struct {
}
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{
Title: "API documentation",
Description: "Curl and ShareX upload examples for Warpbox.",
Data: apiDocsData{
BaseURL: a.cfg.BaseURL,
UploadURL: a.cfg.BaseURL + "/api/v1/upload",
HealthURL: a.cfg.BaseURL + "/api/v1/health",
RequestSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-request.json",
ResponseSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-response.json",
ShareXExamplePath: "examples/sharex/warpbox-anonymous.sxcu",

View File

@@ -16,12 +16,13 @@ type App struct {
uploadService *services.UploadService
authService *services.AuthService
settingsService *services.SettingsService
reactionService *services.ReactionService
banService *services.BanService
rateLimiter *rateLimiter
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{
cfg: cfg,
logger: logger,
@@ -29,6 +30,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService,
authService: authService,
settingsService: settingsService,
reactionService: reactionService,
banService: banService,
rateLimiter: newRateLimiter(),
uploadGroups: newUploadGrouper(),
@@ -121,16 +123,22 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
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}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /health", a.Health)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)
mux.HandleFunc("GET /healthz", notFound)
mux.HandleFunc("GET /api/v1/health", notFound)
mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig)
mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema)
mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema)
mux.HandleFunc("POST /api/v1/upload", a.Upload)
mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset)
mux.Handle("GET /static/", a.Static())
}
func notFound(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}

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) {
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."})
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"))
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()})
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")
}
@@ -58,12 +58,13 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app", http.StatusSeeOther)
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")})
}
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
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."})
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"))
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.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
return
}
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)
}
@@ -92,7 +93,7 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
return
}
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 {
_ = 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."})
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 != ""})
}
@@ -114,7 +116,7 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
invite, err := a.authService.InviteByToken(token)
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."})
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"))
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()})
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")
}
@@ -153,6 +155,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
if !ok {
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{})
}
@@ -170,11 +173,11 @@ func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
}
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
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."})
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})
}
@@ -184,9 +187,9 @@ func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
return
}
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 {
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)
}
@@ -233,16 +236,16 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
return
}
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)
return
}
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)
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)
}

View File

@@ -42,6 +42,12 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
if !ok {
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)
if err != nil {
http.Error(w, "unable to load collections", http.StatusInternalServerError)
@@ -112,9 +118,9 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
return
}
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 {
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)
}
@@ -129,11 +135,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
return
}
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)
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)
}
@@ -148,16 +154,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
}
collectionID := r.FormValue("collection_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)
return
}
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)
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)
}
@@ -167,11 +173,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
return
}
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)
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)
}

View File

@@ -2,12 +2,15 @@ package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -26,6 +29,7 @@ type downloadPageData struct {
DownloadCount int
MaxDownloads int
ExpiresLabel string
EmojiTabs []emojiTabView
}
type boxView struct {
@@ -41,6 +45,28 @@ type fileView struct {
URL string
DownloadURL 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 {
@@ -53,12 +79,12 @@ type previewPageData struct {
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
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)
return
}
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{
Title: "Download unavailable",
Description: "This Warpbox link is no longer available.",
@@ -70,13 +96,22 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return
}
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))
if !(locked && box.Obfuscate) {
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")
title := "Shared files on Warpbox"
@@ -99,9 +134,10 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
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 {
@@ -139,7 +175,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
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) {
@@ -148,13 +184,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
return
}
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)
return
}
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) {
@@ -202,7 +238,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
return
}
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID, "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)
return
}
@@ -215,26 +251,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
Secure: r.TLS != nil,
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)
}
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
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)
return services.Box{}, services.File{}, false
}
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))
return services.Box{}, services.File{}, false
}
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
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)
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) {
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
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)
return
}
@@ -280,17 +316,17 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
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)
return
}
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))
return
}
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)
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) {
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 {
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{
ID: file.ID,
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),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", 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 {
if !a.uploadService.IsProtected(box) {
return true

View File

@@ -13,6 +13,10 @@ type healthResponse struct {
}
func (a *App) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/health" {
http.NotFound(w, r)
return
}
helpers.WriteJSON(w, http.StatusOK, healthResponse{
Status: "ok",
Time: time.Now().UTC().Format(time.RFC3339),

View File

@@ -13,9 +13,7 @@ func TestHealthRoutes(t *testing.T) {
mux := http.NewServeMux()
app.RegisterRoutes(mux)
for _, path := range []string{"/health", "/healthz", "/api/v1/health"} {
t.Run(path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, path, nil)
request := httptest.NewRequest(http.MethodGet, "/health", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
@@ -23,6 +21,12 @@ func TestHealthRoutes(t *testing.T) {
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
})
for _, path := range []string{"/healthz", "/api/v1/health"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("%s status = %d, want 404", path, response.Code)
}
}
}

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.",
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) {
@@ -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 {
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)
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)
}
@@ -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) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
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)
return services.Box{}, false
}
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)
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)
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)
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{

View File

@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"os"
"path/filepath"
"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) {
ext := strings.ToLower(filepath.Ext(path))

View File

@@ -18,7 +18,7 @@ import (
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
user, loggedIn, authErr := a.currentUserWithAuthError(r)
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")
return
}
@@ -30,14 +30,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
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")
return
}
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
rateKey := uploadRateKey(r, user, loggedIn)
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")
return
}
@@ -52,7 +52,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
parseLimit = 32 << 20
}
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")
return
}
@@ -65,14 +65,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
ownerID = user.ID
collectionID = r.FormValue("collection_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")
return
}
}
if !isAdminUpload {
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)
return
}
@@ -89,7 +89,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
}
}
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))
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.
if expiresMinutes < 0 || rawMaxDays < 0 {
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))
return
}
expiresMinutes = -1
} 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))
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)
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)
return
}
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())
return
}
@@ -141,7 +141,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
}
}
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) {
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) {
app, cleanup := newTestApp(t)
defer cleanup()
@@ -198,6 +234,14 @@ func newTestApp(t *testing.T) (*App, func()) {
if err != nil {
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)
if err != nil {
service.Close()
@@ -213,12 +257,17 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close()
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())
if err != nil {
service.Close()
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 {
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()
return nil, err
}
reactionService, err := services.NewReactionService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
banService, err := services.NewBanService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
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()
app.RegisterRoutes(router)
@@ -50,13 +55,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
middleware.SecurityHeaders,
middleware.Gzip,
middleware.ClientIP(cfg.TrustedProxies),
middleware.Logger(logger),
middleware.Bans(logger, banService, cfg.TrustedProxies),
)
server := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
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

@@ -472,7 +472,7 @@ func (s *BanService) MaliciousPattern(path string) (string, error) {
}
func shouldSkipMaliciousPath(path string) bool {
return path == "/health" || path == "/healthz" || path == "/api/v1/health" || strings.HasPrefix(path, "/static/")
return path == "/health" || strings.HasPrefix(path, "/static/")
}
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {

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 {
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})
if err != nil {
@@ -957,6 +960,10 @@ func randomID(byteCount int) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func RandomPublicToken(byteCount int) string {
return randomID(byteCount)
}
func hashPassword(password string) (string, string) {
salt := randomID(18)
return salt, passwordHash(salt, password)

View File

@@ -65,6 +65,242 @@
.file-card {
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 {

View File

@@ -196,49 +196,65 @@
}
.bar-chart {
display: flex;
align-items: stretch;
display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr));
align-items: end;
gap: 0.4rem;
height: 180px;
min-height: 13rem;
margin-top: 1.25rem;
padding-top: 0.5rem;
}
.bar-chart-col {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
align-items: center;
align-items: stretch;
gap: 0.35rem;
height: 100%;
}
.bar-chart-track {
width: 100%;
max-width: 2.2rem;
min-height: 0;
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, #8b5cf6), color-mix(in srgb, var(--primary, #8b5cf6) 55%, transparent));
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
}
.bar-chart-value {
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;
}
@@ -260,6 +276,8 @@
}
.stat-bar-track {
display: block;
width: 100%;
margin-top: 0.35rem;
height: 0.55rem;
border-radius: 999px;
@@ -270,6 +288,7 @@
.stat-bar-fill {
display: block;
height: 100%;
min-width: 0;
border-radius: 999px;
background: var(--primary, #8b5cf6);
}

View File

@@ -220,6 +220,16 @@
grid-template-columns: 1fr;
}
.file-reaction-dock {
right: 0.5rem;
bottom: 0.45rem;
}
.reaction-button {
opacity: 1;
transform: none;
}
.file-progress-side {
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

@@ -31,7 +31,9 @@
<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/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/25-admin-charts.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>

View File

@@ -65,9 +65,9 @@
<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}}">
<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" style="height: {{.Height}}%"></span></span>
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span>
</div>
{{end}}
@@ -81,9 +81,9 @@
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
<div class="stat-bars">
{{range .Data.Overview.StatusBars}}
<div class="stat-bar">
<div class="stat-bar" data-stat-value="{{.RawValue}}">
<span>{{.Label}} <strong>{{.Value}}</strong></span>
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
<span class="stat-bar-track"><span class="stat-bar-fill" data-width-percent="{{.WidthPercent}}" style="width: {{.WidthPercent}}%"></span></span>
</div>
{{end}}
</div>
@@ -97,9 +97,9 @@
<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}}">
<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" style="height: {{.Height}}%"></span></span>
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span>
</div>
{{end}}

View File

@@ -14,7 +14,7 @@
<h2>Endpoints</h2>
<dl class="endpoint-list">
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div>
<div><dt>Health</dt><dd><code>GET /api/v1/health</code></dd></div>
<div><dt>Health</dt><dd><code>GET /health</code></dd></div>
<div><dt>Request schema</dt><dd><a href="/api/v1/schemas/upload-request.json"><code>/api/v1/schemas/upload-request.json</code></a></dd></div>
<div><dt>Response schema</dt><dd><a href="/api/v1/schemas/upload-response.json"><code>/api/v1/schemas/upload-response.json</code></a></dd></div>
</dl>

View File

@@ -44,7 +44,7 @@
<div class="download-list file-browser is-list" data-file-browser>
{{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}}">
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
</a>
@@ -64,11 +64,54 @@
Download
</a>
</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}}
</article>
{{end}}
</div>
{{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-top">
<small>File actions</small>

View File

@@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
WARPBOX_IDLE_TIMEOUT=120s
WARPBOX_TRUSTED_PROXIES=