8 Commits

Author SHA1 Message Date
60d2ea0204 fix(admin): improve overview bar chart layout and alignment
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m42s
2026-06-01 11:08:24 +03:00
ffa2d9636b feat(admin): add dashboard overview charts and log pagination
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Enhance the admin panel by introducing visual overview charts for upload and storage trends, along with status bars for system metrics.

Additionally, implement pagination for the admin logs view, allowing users to navigate through log entries with configurable page sizes. Corresponding CSS styles have been added for the new charts, metrics grid, and pagination controls.
2026-06-01 04:22:38 +03:00
cc91ce120d feat(admin): allow editing boxes and deleting individual files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
Introduce new admin capabilities to manage uploaded boxes and files:
- Add routes and handlers for editing boxes and deleting individual files.
- Implement `RemoveFileFromBox` in `UploadService` to delete a file's stored objects and remove it from the box (deleting the box if empty).
- Implement `AdminUpdateBox` in `UploadService` to update expiry, download limits, and clear password protection.
- Remove the unused `AdminFiles` handler.
- Add `.claude` to `.gitignore`.
2026-06-01 03:39:45 +03:00
73bd14572d feat(storage): support deleting backends and improve admin UI
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m41s
- Implement storage backend deletion, which automatically resets default storage settings and user-specific overrides when a backend is removed.
- Add unit tests covering the delete action and its cleanup side effects.
- Improve admin UI responsiveness, fixing table scrolling, flex wrapping, and text truncation for long storage backend names.
- Update security documentation to clarify trusted proxy configurations and explain how trusted proxies are protected from automatic bans.
2026-06-01 02:24:51 +03:00
4eacb4cde2 fix(handlers): bypass box creation limits for batched uploads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m42s
Update `createOrAppendBox` to accept the upload policy and admin status, allowing policy enforcement to be handled during the box creation/append decision process. This ensures that appending files to an existing batch does not incorrectly trigger daily or active box creation limits, as no new box is being created.

Also, add unit tests to verify that batched uploads successfully bypass both daily and active box creation caps.
2026-06-01 00:20:18 +03:00
71d9b9db7e perf(backend): optimize ban lookups and prune upload group map
- Optimize the ban matching middleware by using a read-only transaction (`db.View`) for the initial scan, avoiding the single bbolt write lock on every request when no ban matches.
- Implement periodic pruning of stale entries in the upload grouper map to prevent unbounded memory growth over time.
- Avoid redundant parsing of the `max_days` form value in the upload handler.
2026-06-01 00:12:43 +03:00
01996c0445 feat(policy): support unlimited values in user policies and box expiry
- Update user policy and user update handlers to accept -1 as an unlimited value for MaxDays, DailyBoxes, ActiveBoxes, and ShortWindowRequests.
- Introduce `optionalIntAllowUnlimited` helper and update `optionalMBAllowZero` to support -1.
- Use `boxExpiryLabel` helper across admin, dashboard, and download handlers to properly format expiration dates, supporting boxes that never expire.
2026-05-31 22:40:48 +03:00
adb1a12dfd feat(upload): support batching via header and update ShareX config
Introduce support for grouping multiple sequential file uploads into a single box using the `X-Warpbox-Batch` header. This is particularly useful for ShareX multi-file selections, which are sent as separate back-to-back requests.

Additionally, this change:
- Updates the ShareX configuration template to opt-in to batching by default.
- Switches ShareX configuration placeholders to the modern `{json:...}` format.
- Adds `thumbnailUrl` to the upload response schema and documents its usage.
2026-05-31 22:27:43 +03:00
52 changed files with 3178 additions and 364 deletions

2
.gitignore vendored
View File

@@ -15,3 +15,5 @@ backend/static/uploads/*
.prod.env .prod.env
scripts/env/dev.env scripts/env/dev.env
docker-compose.yml docker-compose.yml
.claude

View File

@@ -182,7 +182,7 @@ Curl and custom uploaders can use the same endpoint:
# Terminal-friendly output: one plain box URL. # Terminal-friendly output: one plain box URL.
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
# JSON output with boxUrl, manageUrl, deleteUrl, zipUrl, and file entries. # JSON output with boxUrl, thumbnailUrl, manageUrl, deleteUrl, zipUrl, and file entries.
curl -F sharex=@./screenshot.png \ curl -F sharex=@./screenshot.png \
-H 'Accept: application/json' \ -H 'Accept: application/json' \
http://localhost:8080/api/v1/upload http://localhost:8080/api/v1/upload
@@ -190,6 +190,19 @@ curl -F sharex=@./screenshot.png \
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL. from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
Authenticated uploads (your account's limits) add an `Authorization: Bearer <token>` header — mint
a token under **Account → Access tokens**. The JSON response uses ShareX placeholders
`{json:boxUrl}` (URL), `{json:thumbnailUrl}` (thumbnail), `{json:deleteUrl}` (deletion), and
`{json:error}` (error message).
### Grouping multiple files into one box (`X-Warpbox-Batch`)
By default every uploaded file becomes its own box. To put several files in a **single** box, send
the opt-in `X-Warpbox-Batch` header: requests that share the same header value (scoped per account,
or per IP for anonymous uploads) within 20s are appended to the same box. This lets a multi-file
ShareX selection — which ShareX sends as separate back-to-back requests — land as one shareable
link. The shipped `.sxcu` sets `X-Warpbox-Batch: sharex`; remove that header for one box per file.
Requests without the header behave exactly as before.
## Stage 4 Accounts + Personal Boxes ## Stage 4 Accounts + Personal Boxes

View File

@@ -24,10 +24,10 @@ public internet.
## Trusted Proxies ## Trusted Proxies
For stricter deployments, set `WARPBOX_TRUSTED_PROXIES` to the IPs or CIDR For stricter deployments, set `WARPBOX_TRUSTED_PROXIES` to the IPs or CIDR
ranges that are allowed to provide forwarded headers: ranges that are allowed to provide forwarded headers. Use proxy IPs only.
```env ```env
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12,10.0.0.0/8 WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.30.0.1
``` ```
When this value is set, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` only When this value is set, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` only
@@ -37,9 +37,15 @@ directly from any other IP ignore forwarded headers and use the socket address.
Recommended values: Recommended values:
- Same-host Caddy with systemd: `127.0.0.1,::1` - Same-host Caddy with systemd: `127.0.0.1,::1`
- Docker bridge networks: add the bridge CIDR, often `172.16.0.0/12` - Docker/Podman bridge gateway: add the exact gateway IP, for example `172.30.0.1`
- Docker bridge networks: use a CIDR such as `172.16.0.0/12` only if the exact gateway changes often
- Private reverse-proxy networks: add the exact private CIDR used by the proxy - Private reverse-proxy networks: add the exact private CIDR used by the proxy
Warpbox prefers the first public address in `X-Forwarded-For` when a trusted
proxy sends a chain. Loopback addresses and trusted proxy addresses are also
protected from manual and automatic bans so a bad header setup cannot ban Caddy,
the container gateway, or Warpbox itself.
## Direct Exposure ## Direct Exposure
If you expose Warpbox directly without Caddy, either leave If you expose Warpbox directly without Caddy, either leave

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -414,6 +415,80 @@ func TestLayeredUploadLimits(t *testing.T) {
} }
} }
func TestBatchedUploadAppendBypassesDailyBoxCreationCap(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousDailyBoxes = 1
policy.AnonymousActiveBoxes = 10
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
first := multipartUploadRequest(t, "/api/v1/upload", "file", "first.txt", "hello")
first.Header.Set("Accept", "application/json")
first.Header.Set(uploadBatchHeader, "sharex-test")
firstResponse := httptest.NewRecorder()
app.Upload(firstResponse, first)
if firstResponse.Code != http.StatusCreated {
t.Fatalf("first batched status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
}
second := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
second.Header.Set("Accept", "application/json")
second.Header.Set(uploadBatchHeader, "sharex-test")
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, second)
if secondResponse.Code != http.StatusCreated {
t.Fatalf("second batched status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
third := multipartUploadRequest(t, "/api/v1/upload", "file", "third.txt", "hello")
third.Header.Set("Accept", "application/json")
thirdResponse := httptest.NewRecorder()
app.Upload(thirdResponse, third)
if thirdResponse.Code != http.StatusTooManyRequests {
t.Fatalf("non-batched status = %d, body = %s", thirdResponse.Code, thirdResponse.Body.String())
}
}
func TestBatchedUploadAppendBypassesActiveBoxCreationCap(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousDailyBoxes = 10
policy.AnonymousActiveBoxes = 1
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
first := multipartUploadRequest(t, "/api/v1/upload", "file", "first.txt", "hello")
first.Header.Set("Accept", "application/json")
first.Header.Set(uploadBatchHeader, "active-cap")
firstResponse := httptest.NewRecorder()
app.Upload(firstResponse, first)
if firstResponse.Code != http.StatusCreated {
t.Fatalf("first batched status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
}
second := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
second.Header.Set("Accept", "application/json")
second.Header.Set(uploadBatchHeader, "active-cap")
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, second)
if secondResponse.Code != http.StatusCreated {
t.Fatalf("second batched status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
third := multipartUploadRequest(t, "/api/v1/upload", "file", "third.txt", "hello")
third.Header.Set("Accept", "application/json")
thirdResponse := httptest.NewRecorder()
app.Upload(thirdResponse, third)
if thirdResponse.Code != http.StatusTooManyRequests {
t.Fatalf("non-batched status = %d, body = %s", thirdResponse.Code, thirdResponse.Body.String())
}
}
func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) { func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -620,6 +695,35 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
} }
} }
func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
overview := buildAdminOverview([]services.AdminBox{{
ID: "box1",
CreatedAt: today,
TotalSize: 1024,
}}, services.AdminStats{TotalBoxes: 1, TotalFiles: 1, TotalSize: 1024})
for i, bar := range overview.UploadDays {
want := 0
if i == len(overview.UploadDays)-1 {
want = 100
}
if bar.Height != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want)
}
}
for i, bar := range overview.StorageDays {
want := 0
if i == len(overview.StorageDays)-1 {
want = 100
}
if bar.Height != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want)
}
}
}
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) { func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -731,6 +835,101 @@ func TestAdminStorageJobRoutesRequireAdminAndCSRF(t *testing.T) {
} }
} }
func TestAdminStorageDeleteAction(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
Provider: services.StorageProviderWebDAV,
Name: "DAV",
Endpoint: "https://dav.example.test",
Username: "warpbox",
Password: "secret",
RemotePath: "/warpbox",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
deleteRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", strings.NewReader("csrf_token=test-csrf"))
deleteRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
deleteRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
deleteRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
deleteRequest.SetPathValue("backendID", cfg.ID)
deleteResponse := httptest.NewRecorder()
app.AdminDeleteStorage(deleteResponse, deleteRequest)
if deleteResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminDeleteStorage status = %d, body = %s", deleteResponse.Code, deleteResponse.Body.String())
}
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
}
}
func TestAdminStorageDeleteResetsDefaultsAndUserOverrides(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
user, err := app.authService.UserByEmail("admin@example.test")
if err != nil {
t.Fatalf("UserByEmail returned error: %v", err)
}
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
Provider: services.StorageProviderWebDAV,
Name: "DAV",
Endpoint: "https://dav.example.test",
Username: "warpbox",
Password: "secret",
RemotePath: "/warpbox",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
settings, err := app.settingsService.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
settings.UserStorageBackend = cfg.ID
if err := app.settingsService.UpdateUploadPolicy(settings); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
if err := app.authService.SetUserStorageBackend(user.ID, cfg.ID); err != nil {
t.Fatalf("SetUserStorageBackend returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", strings.NewReader("csrf_token=test-csrf"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
request.SetPathValue("backendID", cfg.ID)
response := httptest.NewRecorder()
app.AdminDeleteStorage(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("AdminDeleteStorage status = %d, body = %s", response.Code, response.Body.String())
}
location := response.Header().Get("Location")
if !strings.Contains(location, "Storage+backend+deleted") || !strings.Contains(location, "cleared+1+user+overrides") {
t.Fatalf("delete redirect did not include cascade notice: %s", location)
}
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
}
nextSettings, err := app.settingsService.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if nextSettings.UserStorageBackend != services.StorageBackendLocal {
t.Fatalf("UserStorageBackend = %q, want local", nextSettings.UserStorageBackend)
}
nextUser, err := app.authService.UserByID(user.ID)
if err != nil {
t.Fatalf("UserByID returned error: %v", err)
}
if nextUser.Policy.StorageBackendID != nil {
t.Fatalf("user storage override was not cleared: %+v", nextUser.Policy)
}
}
func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) { func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -814,8 +1013,12 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
t.Fatalf("MkdirAll returned error: %v", err) t.Fatalf("MkdirAll returned error: %v", err)
} }
logPath := filepath.Join(logDir, "2026-05-31.log") logPath := filepath.Join(logDir, "2026-05-31.log")
line := `{"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"}` + "\n" lines := strings.Join([]string{
if err := os.WriteFile(logPath, []byte(line), 0o644); err != nil { `{"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"}`,
"",
}, "\n")
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err) t.Fatalf("WriteFile returned error: %v", err)
} }
@@ -830,6 +1033,9 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") { if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") {
t.Fatalf("AdminLogs missing expected log entry: %s", logsBody) t.Fatalf("AdminLogs missing expected log entry: %s", logsBody)
} }
if strings.Contains(logsBody, "172.30.0.1:48358") {
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
}
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil) bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})

View File

@@ -17,6 +17,7 @@ import (
"strings" "strings"
"time" "time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs" "warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
@@ -36,6 +37,7 @@ type adminPageData struct {
StorageTypes []adminStorageProviderView StorageTypes []adminStorageProviderView
Logs adminLogsView Logs adminLogsView
Bans adminBansView Bans adminBansView
Overview adminOverview
Section string Section string
PageTitle string PageTitle string
LastInviteURL string LastInviteURL string
@@ -44,16 +46,32 @@ type adminPageData struct {
} }
type adminLogsView struct { type adminLogsView struct {
Entries []adminLogEntry Entries []adminLogEntry
Dates []string Dates []string
Date string Date string
Severity string Severity string
Source string Source string
Query string Query string
Sort string Sort string
TotalShown int TotalShown int
Total int
Page int
PerPage int
PerPageOptions []int
TotalPages int
RangeFrom int
RangeTo int
PageLinks []adminFilesPageLink
HasPrev bool
HasNext bool
PrevHref string
NextHref string
} }
var adminLogsPageSizes = []int{50, 100, 250, 500}
const adminLogsDefaultPageSize = 100
type adminLogEntry struct { type adminLogEntry struct {
Date string Date string
Time string Time string
@@ -134,6 +152,24 @@ type adminStorageProviderView struct {
Icon string Icon string
} }
type adminOverview struct {
UploadDays []adminChartBar
StorageDays []adminChartBar
StatusBars []adminStatBar
}
type adminChartBar struct {
Label string
Value string
Height int // 0-100, percent of the tallest bar
}
type adminStatBar struct {
Label string
Value string
Percent int
}
type adminBoxView struct { type adminBoxView struct {
ID string ID string
Owner string Owner string
@@ -248,52 +284,140 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError) http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return return
} }
boxes, err := a.adminBoxes(8) allBoxes, err := a.uploadService.AdminBoxes(0)
if err != nil { if err != nil {
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError) http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return return
} }
overview := buildAdminOverview(allBoxes, stats)
recent := a.recentBoxViews(allBoxes, 8)
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
Title: "Admin overview", Title: "Admin overview",
Description: "Warpbox admin overview.", Description: "Warpbox admin overview.",
CurrentUser: a.currentPublicUser(r), CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, Boxes: recent,
Overview: overview,
Section: "overview", Section: "overview",
PageTitle: "Admin overview", PageTitle: "Admin overview",
}, },
}) })
} }
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) { // recentBoxViews renders the newest boxes (already sorted newest-first by the
if !a.requireAdmin(w, r) { // service) into display rows, resolving owner labels.
return func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxView {
if limit > 0 && len(boxes) > limit {
boxes = boxes[:limit]
}
cache := map[string]string{}
rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes {
rows = append(rows, adminBoxView{
ID: box.ID,
Owner: a.boxOwnerLabel(box.OwnerID, cache),
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04"),
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04"),
FileCount: box.FileCount,
TotalSizeLabel: box.TotalSizeLabel,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: box.Protected,
Expired: box.Expired,
})
}
return rows
}
// buildAdminOverview computes the last-14-day upload/storage series plus a few
// status distributions for the overview dashboard.
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
const days = 14
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
counts := make([]int, days)
bytes := make([]int64, days)
labels := make([]string, days)
for i := 0; i < days; i++ {
day := today.AddDate(0, 0, -(days - 1 - i))
labels[i] = day.Format("Jan 2")
} }
stats, err := a.uploadService.AdminStats() for _, box := range boxes {
if err != nil { created := box.CreatedAt.UTC()
http.Error(w, "unable to load admin stats", http.StatusInternalServerError) day := time.Date(created.Year(), created.Month(), created.Day(), 0, 0, 0, 0, time.UTC)
return offset := int(today.Sub(day).Hours() / 24)
} idx := days - 1 - offset
boxes, err := a.adminBoxes(100) if idx < 0 || idx >= days {
if err != nil { continue
http.Error(w, "unable to load boxes", http.StatusInternalServerError) }
return counts[idx]++
bytes[idx] += box.TotalSize
} }
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{ maxCount := 0
Title: "Admin files", var maxBytes int64
Description: "Manage Warpbox uploads.", for i := 0; i < days; i++ {
CurrentUser: a.currentPublicUser(r), if counts[i] > maxCount {
Data: adminPageData{ maxCount = counts[i]
Stats: stats, }
Boxes: boxes, if bytes[i] > maxBytes {
Section: "files", maxBytes = bytes[i]
PageTitle: "Admin files", }
}, }
})
uploadDays := make([]adminChartBar, days)
storageDays := make([]adminChartBar, days)
for i := 0; i < days; i++ {
uploadDays[i] = adminChartBar{
Label: labels[i],
Value: strconv.Itoa(counts[i]),
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
}
storageDays[i] = adminChartBar{
Label: labels[i],
Value: helpers.FormatBytes(bytes[i]),
Height: scaleHeight(bytes[i], maxBytes),
}
}
activeBoxes := stats.TotalBoxes - stats.ExpiredBoxes
if activeBoxes < 0 {
activeBoxes = 0
}
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)},
}
return adminOverview{
UploadDays: uploadDays,
StorageDays: storageDays,
StatusBars: statusBars,
}
}
func scaleHeight(value, max int64) int {
if max <= 0 || value <= 0 {
return 0
}
height := int(value * 100 / max)
if height < 4 {
height = 4
}
return height
}
func percentOf(value, total int) int {
if total <= 0 || value <= 0 {
return 0
}
return value * 100 / total
} }
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) { func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
@@ -564,6 +688,10 @@ func (a *App) AdminCreateBan(w http.ResponseWriter, r *http.Request) {
if user, ok := a.currentUser(r); ok { if user, ok := a.currentUser(r); ok {
createdBy = user.ID createdBy = user.ID
} }
if services.ProtectedBanTarget(r.FormValue("target"), a.cfg.TrustedProxies) {
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Refusing to ban loopback or trusted proxy addresses."), http.StatusSeeOther)
return
}
ban, err := a.banService.CreateManualBan(r.FormValue("target"), r.FormValue("reason"), createdBy, expiresAt.UTC()) ban, err := a.banService.CreateManualBan(r.FormValue("target"), r.FormValue("reason"), createdBy, expiresAt.UTC())
if err != nil { if err != nil {
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
@@ -883,32 +1011,45 @@ func (a *App) AdminStartStorageSpeedTest(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?notice="+url.QueryEscape("Storage speed test started in the background."), http.StatusSeeOther) http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?notice="+url.QueryEscape("Storage speed test started in the background."), http.StatusSeeOther)
} }
func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
id := r.PathValue("backendID")
inUse, _ := a.storageBackendInUse(id)
if err := a.uploadService.Storage().DisableBackend(id, inUse); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
a.logger.Info("storage backend disabled", "source", "admin", "severity", "user_activity", "code", 2324, "ip", uploadClientIP(r), "backend_id", id)
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) { func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return return
} }
id := r.PathValue("backendID") id := r.PathValue("backendID")
inUse, _ := a.storageBackendInUse(id) cfg, err := a.uploadService.Storage().BackendConfig(id)
if err := a.uploadService.Storage().DeleteBackend(id, inUse); err != nil { if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return return
} }
if cfg.ID == services.StorageBackendLocal {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape("local storage cannot be deleted"), http.StatusSeeOther)
return
}
deletedBoxes, err := a.uploadService.DeleteBoxesForStorageBackend(id, "storage-delete")
if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
resetAnonymous, resetUsersDefault, err := a.settingsService.ResetStorageBackend(id)
if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
clearedUsers, err := a.authService.ClearStorageBackendOverrides(id)
if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
if err := a.uploadService.Storage().DeleteBackend(id, false); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
notice := fmt.Sprintf("Storage backend deleted. Removed %d related boxes and cleared %d user overrides.", deletedBoxes, clearedUsers)
if resetAnonymous || resetUsersDefault {
notice += " Global storage defaults were reset to local."
}
a.logger.Info("storage backend deleted", "source", "admin", "severity", "user_activity", "code", 2325, "ip", uploadClientIP(r), "backend_id", id) a.logger.Info("storage backend deleted", "source", "admin", "severity", "user_activity", "code", 2325, "ip", uploadClientIP(r), "backend_id", id)
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(notice), http.StatusSeeOther)
} }
func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) { func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) {
@@ -1004,10 +1145,10 @@ func (a *App) AdminUpdateUserPolicy(w http.ResponseWriter, r *http.Request) {
MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")), MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")),
DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")), DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")),
StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")), StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")),
MaxDays: optionalInt(r.FormValue("max_days")), MaxDays: optionalIntAllowUnlimited(r.FormValue("max_days")),
DailyBoxes: optionalInt(r.FormValue("daily_boxes")), DailyBoxes: optionalIntAllowUnlimited(r.FormValue("daily_boxes")),
ActiveBoxes: optionalInt(r.FormValue("active_boxes")), ActiveBoxes: optionalIntAllowUnlimited(r.FormValue("active_boxes")),
ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")), ShortWindowRequests: optionalIntAllowUnlimited(r.FormValue("short_window_requests")),
} }
if backendID := r.FormValue("storage_backend_id"); backendID != "" { if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil { if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
@@ -1036,10 +1177,10 @@ func (a *App) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")), MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")),
DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")), DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")),
StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")), StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")),
MaxDays: optionalInt(r.FormValue("max_days")), MaxDays: optionalIntAllowUnlimited(r.FormValue("max_days")),
DailyBoxes: optionalInt(r.FormValue("daily_boxes")), DailyBoxes: optionalIntAllowUnlimited(r.FormValue("daily_boxes")),
ActiveBoxes: optionalInt(r.FormValue("active_boxes")), ActiveBoxes: optionalIntAllowUnlimited(r.FormValue("active_boxes")),
ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")), ShortWindowRequests: optionalIntAllowUnlimited(r.FormValue("short_window_requests")),
} }
if backendID := r.FormValue("storage_backend_id"); backendID != "" { if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil { if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
@@ -1186,38 +1327,6 @@ func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status in
}) })
} }
func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
boxes, err := a.uploadService.AdminBoxes(limit)
if err != nil {
return nil, err
}
rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes {
owner := "Anonymous"
if box.OwnerID != "" {
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
owner = user.Email
} else {
owner = "User"
}
}
rows = append(rows, adminBoxView{
ID: box.ID,
Owner: owner,
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
FileCount: box.FileCount,
TotalSizeLabel: box.TotalSizeLabel,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: box.Protected,
Expired: box.Expired,
})
}
return rows, nil
}
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool { func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if a.isAdmin(r) { if a.isAdmin(r) {
return true return true
@@ -1296,7 +1405,8 @@ func optionalMBAllowZero(value string) *float64 {
return nil return nil
} }
parsed, err := strconv.ParseFloat(value, 64) parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 { // 0 and -1 both mean unlimited; reject other negatives.
if err != nil || (parsed < 0 && parsed != -1) {
return nil return nil
} }
return &parsed return &parsed
@@ -1313,6 +1423,18 @@ func optionalInt(value string) *int {
return &parsed return &parsed
} }
// optionalIntAllowUnlimited is like optionalInt but also accepts -1 (unlimited).
func optionalIntAllowUnlimited(value string) *int {
if value == "" {
return nil
}
parsed, err := strconv.Atoi(value)
if err != nil || (parsed <= 0 && parsed != -1) {
return nil
}
return &parsed
}
func formatMB(value float64) string { func formatMB(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) + " MB" return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
} }
@@ -1464,21 +1586,111 @@ func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) {
} }
return left > right return left > right
}) })
if len(entries) > 500 {
entries = entries[:500] perPage := normalizePageSize(r.URL.Query().Get("per"), adminLogsDefaultPageSize, adminLogsPageSizes)
total := len(entries)
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
} }
page := 1
if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 {
page = parsed
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
rangeFrom := 0
if total > 0 {
rangeFrom = start + 1
}
state := adminLogsQuery{
Date: selectedDate,
Severity: severity,
Source: source,
Query: r.URL.Query().Get("q"),
Sort: sortOrder,
Per: perPage,
}
links := make([]adminFilesPageLink, 0, 5)
for p := page - 2; p <= page+2; p++ {
if p < 1 || p > totalPages {
continue
}
links = append(links, adminFilesPageLink{Page: p, Href: adminLogsHref(state, p), Active: p == page})
}
return adminLogsView{ return adminLogsView{
Entries: entries, Entries: entries[start:end],
Dates: dates, Dates: dates,
Date: selectedDate, Date: selectedDate,
Severity: severity, Severity: severity,
Source: source, Source: source,
Query: r.URL.Query().Get("q"), Query: r.URL.Query().Get("q"),
Sort: sortOrder, Sort: sortOrder,
TotalShown: len(entries), TotalShown: end - start,
Total: total,
Page: page,
PerPage: perPage,
PerPageOptions: adminLogsPageSizes,
TotalPages: totalPages,
RangeFrom: rangeFrom,
RangeTo: end,
PageLinks: links,
HasPrev: page > 1,
HasNext: page < totalPages,
PrevHref: adminLogsHref(state, page-1),
NextHref: adminLogsHref(state, page+1),
}, nil }, nil
} }
type adminLogsQuery struct {
Date string
Severity string
Source string
Query string
Sort string
Per int
}
func adminLogsHref(state adminLogsQuery, page int) string {
values := url.Values{}
if state.Date != "" {
values.Set("date", state.Date)
}
if state.Severity != "" {
values.Set("severity", state.Severity)
}
if state.Source != "" {
values.Set("source", state.Source)
}
if state.Query != "" {
values.Set("q", state.Query)
}
if state.Sort != "" && state.Sort != "desc" {
values.Set("sort", state.Sort)
}
if state.Per > 0 && state.Per != adminLogsDefaultPageSize {
values.Set("per", strconv.Itoa(state.Per))
}
if page > 1 {
values.Set("page", strconv.Itoa(page))
}
if len(values) == 0 {
return "/admin/logs"
}
return "/admin/logs?" + values.Encode()
}
func availableLogDates(logDir string) ([]string, error) { func availableLogDates(logDir string) ([]string, error) {
matches, err := filepath.Glob(filepath.Join(logDir, "*.log")) matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
if err != nil { if err != nil {
@@ -1535,7 +1747,7 @@ func logEntryFromMap(raw map[string]any) adminLogEntry {
Method: logString(raw, "method"), Method: logString(raw, "method"),
Path: logString(raw, "path"), Path: logString(raw, "path"),
Status: logAnyString(raw["status"]), Status: logAnyString(raw["status"]),
IP: firstLogString(raw, "ip", "client_ip", "remote_addr"), IP: services.IPOnly(firstLogString(raw, "ip", "client_ip", "remote_addr")),
UserID: logString(raw, "user_id"), UserID: logString(raw, "user_id"),
} }
entry.Details = logDetails(raw) entry.Details = logDetails(raw)
@@ -1754,13 +1966,14 @@ func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
usage, _ = concrete.Usage(context.Background()) usage, _ = concrete.Usage(context.Background())
} }
} }
inUse, _ := a.storageBackendInUse(cfg.ID) inUse, inUseReason, _ := a.storageBackendUseReason(cfg.ID)
speedTests, _ := a.uploadService.Storage().ListSpeedTests(cfg.ID, 25) speedTests, _ := a.uploadService.Storage().ListSpeedTests(cfg.ID, 25)
views = append(views, services.StorageBackendView{ views = append(views, services.StorageBackendView{
Config: cfg, Config: cfg,
UsageBytes: usage, UsageBytes: usage,
UsageLabel: services.FormatMegabytesFromBytes(usage), UsageLabel: services.FormatMegabytesFromBytes(usage),
InUse: inUse, InUse: inUse,
InUseReason: inUseReason,
SpeedTests: speedTests, SpeedTests: speedTests,
CanSpeedTest: cfg.LastTestSuccess, CanSpeedTest: cfg.LastTestSuccess,
}) })
@@ -1809,32 +2022,40 @@ func (a *App) adminUserEdit(user services.User, settings services.UploadPolicySe
} }
func (a *App) storageBackendInUse(id string) (bool, error) { func (a *App) storageBackendInUse(id string) (bool, error) {
inUse, _, err := a.storageBackendUseReason(id)
return inUse, err
}
func (a *App) storageBackendUseReason(id string) (bool, string, error) {
settings, err := a.settingsService.UploadPolicy() settings, err := a.settingsService.UploadPolicy()
if err != nil { if err != nil {
return false, err return false, "", err
} }
if settings.AnonymousStorageBackend == id || settings.UserStorageBackend == id { if settings.AnonymousStorageBackend == id {
return true, nil return true, "selected as the global anonymous storage backend", nil
}
if settings.UserStorageBackend == id {
return true, "selected as the global user storage backend", nil
} }
boxes, err := a.uploadService.ListBoxes(0) boxes, err := a.uploadService.ListBoxes(0)
if err != nil { if err != nil {
return false, err return false, "", err
} }
for _, box := range boxes { for _, box := range boxes {
if a.uploadService.BoxStorageBackendID(box) == id { if a.uploadService.BoxStorageBackendID(box) == id {
return true, nil return true, "used by existing boxes", nil
} }
} }
users, err := a.authService.ListUsers() users, err := a.authService.ListUsers()
if err != nil { if err != nil {
return false, err return false, "", err
} }
for _, user := range users { for _, user := range users {
if user.Policy.StorageBackendID != nil && *user.Policy.StorageBackendID == id { if user.Policy.StorageBackendID != nil && *user.Policy.StorageBackendID == id {
return true, nil return true, "assigned to one or more users", nil
} }
} }
return false, nil return false, "", nil
} }
func floatPtrString(value *float64) string { func floatPtrString(value *float64) string {

View File

@@ -0,0 +1,492 @@
package handlers
import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
const adminFilesDefaultPageSize = 50
var adminFilesPageSizes = []int{25, 50, 100, 200}
type adminFilesData struct {
Stats services.AdminStats
Section string
PageTitle string
Boxes []adminBoxView
Query string
Sort string
Dir string
Page int
PerPage int
PerPageOptions []int
TotalPages int
Total int
RangeFrom int
RangeTo int
Columns []adminFilesColumn
PageLinks []adminFilesPageLink
HasPrev bool
HasNext bool
PrevHref string
NextHref string
}
// adminFilesQuery captures the listing state that every paginated link must
// preserve.
type adminFilesQuery struct {
Query string
Sort string
Dir string
Per int
}
type adminFilesColumn struct {
Label string
Href string
Sorted bool
Ascending bool
}
type adminFilesPageLink struct {
Page int
Href string
Active bool
}
type adminBoxEditData struct {
Section string
PageTitle string
Box adminBoxDetail
Files []adminBoxEditFile
Notice string
Error string
}
type adminBoxDetail struct {
ID string
Owner string
CreatedAt string
ExpiresLabel string
ExpiresInput string
NeverExpires bool
MaxDownloads int
DownloadCount int
FileCount int
TotalSize string
BackendID string
Protected bool
Obfuscated bool
}
type adminBoxEditFile struct {
ID string
Name string
Size string
ContentType string
ThumbnailURL string
DownloadURL string
HasPreview bool
}
// adminFileRow is the sortable/filterable representation of a box.
type adminFileRow struct {
ID string
Owner string
CreatedAt time.Time
ExpiresAt time.Time
FileCount int
DownloadCount int
MaxDownloads int
TotalSize int64
TotalSizeLabel string
Protected bool
Expired bool
}
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
boxes, err := a.uploadService.AdminBoxes(0)
if err != nil {
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return
}
ownerCache := map[string]string{}
rows := make([]adminFileRow, 0, len(boxes))
for _, box := range boxes {
rows = append(rows, adminFileRow{
ID: box.ID,
Owner: a.boxOwnerLabel(box.OwnerID, ownerCache),
CreatedAt: box.CreatedAt,
ExpiresAt: box.ExpiresAt,
FileCount: box.FileCount,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
TotalSize: box.TotalSize,
TotalSizeLabel: box.TotalSizeLabel,
Protected: box.Protected,
Expired: box.Expired,
})
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query != "" {
needle := strings.ToLower(query)
filtered := rows[:0:0]
for _, row := range rows {
if strings.Contains(strings.ToLower(row.ID), needle) || strings.Contains(strings.ToLower(row.Owner), needle) {
filtered = append(filtered, row)
}
}
rows = filtered
}
sortKey := adminFilesSortKey(r.URL.Query().Get("sort"))
dir := r.URL.Query().Get("dir")
if dir != "asc" {
dir = "desc"
}
sortAdminFileRows(rows, sortKey, dir)
perPage := normalizePageSize(r.URL.Query().Get("per"), adminFilesDefaultPageSize, adminFilesPageSizes)
state := adminFilesQuery{Query: query, Sort: sortKey, Dir: dir, Per: perPage}
total := len(rows)
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
page := 1
if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 {
page = parsed
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
views := make([]adminBoxView, 0, end-start)
for _, row := range rows[start:end] {
views = append(views, adminBoxView{
ID: row.ID,
Owner: row.Owner,
CreatedAt: row.CreatedAt.Format("Jan 2, 2006 15:04"),
ExpiresAt: boxExpiryLabel(row.ExpiresAt, "Jan 2, 2006 15:04"),
FileCount: row.FileCount,
TotalSizeLabel: row.TotalSizeLabel,
DownloadCount: row.DownloadCount,
MaxDownloads: row.MaxDownloads,
Protected: row.Protected,
Expired: row.Expired,
})
}
rangeFrom := 0
if total > 0 {
rangeFrom = start + 1
}
a.renderPage(w, r, http.StatusOK, "admin_files.html", web.PageData{
Title: "Admin files",
Description: "Manage Warpbox uploads.",
CurrentUser: a.currentPublicUser(r),
Data: adminFilesData{
Stats: stats,
Section: "files",
PageTitle: "Files",
Boxes: views,
Query: query,
Sort: sortKey,
Dir: dir,
Page: page,
PerPage: perPage,
PerPageOptions: adminFilesPageSizes,
TotalPages: totalPages,
Total: total,
RangeFrom: rangeFrom,
RangeTo: end,
Columns: adminFilesColumns(state, sortKey, dir),
PageLinks: adminFilesPageLinks(state, page, totalPages),
HasPrev: page > 1,
HasNext: page < totalPages,
PrevHref: adminFilesHref(state, page-1),
NextHref: adminFilesHref(state, page+1),
},
})
}
func (a *App) boxOwnerLabel(ownerID string, cache map[string]string) string {
if ownerID == "" {
return "Anonymous"
}
if label, ok := cache[ownerID]; ok {
return label
}
label := "User"
if user, err := a.authService.UserByID(ownerID); err == nil {
label = user.Email
}
cache[ownerID] = label
return label
}
func adminFilesSortKey(value string) string {
switch value {
case "id", "owner", "files", "size", "downloads", "expires", "created":
return value
default:
return "created"
}
}
func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) {
less := func(i, j int) bool {
a, b := rows[i], rows[j]
switch sortKey {
case "id":
return strings.ToLower(a.ID) < strings.ToLower(b.ID)
case "owner":
return strings.ToLower(a.Owner) < strings.ToLower(b.Owner)
case "files":
return a.FileCount < b.FileCount
case "size":
return a.TotalSize < b.TotalSize
case "downloads":
return a.DownloadCount < b.DownloadCount
case "expires":
return a.ExpiresAt.Before(b.ExpiresAt)
default:
return a.CreatedAt.Before(b.CreatedAt)
}
}
sort.SliceStable(rows, func(i, j int) bool {
if dir == "desc" {
return less(j, i)
}
return less(i, j)
})
}
func adminFilesColumns(state adminFilesQuery, sortKey, dir string) []adminFilesColumn {
defs := []struct{ Key, Label string }{
{"id", "Box"},
{"owner", "Owner"},
{"files", "Files"},
{"size", "Size"},
{"downloads", "Downloads"},
{"created", "Created"},
{"expires", "Expires"},
}
columns := make([]adminFilesColumn, 0, len(defs))
for _, def := range defs {
sorted := sortKey == def.Key
nextDir := "asc"
if sorted && dir == "asc" {
nextDir = "desc"
}
colState := state
colState.Sort = def.Key
colState.Dir = nextDir
columns = append(columns, adminFilesColumn{
Label: def.Label,
Href: adminFilesHref(colState, 1),
Sorted: sorted,
Ascending: dir == "asc",
})
}
return columns
}
func adminFilesPageLinks(state adminFilesQuery, page, totalPages int) []adminFilesPageLink {
links := make([]adminFilesPageLink, 0, 5)
const window = 2
for p := page - window; p <= page+window; p++ {
if p < 1 || p > totalPages {
continue
}
links = append(links, adminFilesPageLink{
Page: p,
Href: adminFilesHref(state, p),
Active: p == page,
})
}
return links
}
func adminFilesHref(state adminFilesQuery, page int) string {
values := url.Values{}
if state.Query != "" {
values.Set("q", state.Query)
}
if state.Sort != "" && state.Sort != "created" {
values.Set("sort", state.Sort)
}
if state.Dir != "" && state.Dir != "desc" {
values.Set("dir", state.Dir)
}
if state.Per > 0 && state.Per != adminFilesDefaultPageSize {
values.Set("per", strconv.Itoa(state.Per))
}
if page > 1 {
values.Set("page", strconv.Itoa(page))
}
if len(values) == 0 {
return "/admin/files"
}
return "/admin/files?" + values.Encode()
}
// normalizePageSize parses a requested page size, falling back to def when the
// value is missing or not one of the allowed sizes.
func normalizePageSize(raw string, def int, allowed []int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return def
}
for _, size := range allowed {
if size == parsed {
return parsed
}
}
return def
}
func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return
}
var totalSize int64
files := make([]adminBoxEditFile, 0, len(box.Files))
for _, file := range box.Files {
totalSize += file.Size
files = append(files, adminBoxEditFile{
ID: file.ID,
Name: file.Name,
Size: helpers.FormatBytes(file.Size),
ContentType: file.ContentType,
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
HasPreview: file.PreviewKind == "image" || file.PreviewKind == "video",
})
}
never := neverExpires(box.ExpiresAt)
expiresInput := ""
if !never {
expiresInput = box.ExpiresAt.UTC().Format("2006-01-02T15:04")
}
cache := map[string]string{}
a.renderPage(w, r, http.StatusOK, "admin_box_edit.html", web.PageData{
Title: "Edit box",
Description: "Edit a Warpbox upload.",
CurrentUser: a.currentPublicUser(r),
Data: adminBoxEditData{
Section: "files",
PageTitle: "Edit box",
Notice: r.URL.Query().Get("notice"),
Error: r.URL.Query().Get("error"),
Files: files,
Box: adminBoxDetail{
ID: box.ID,
Owner: a.boxOwnerLabel(box.OwnerID, cache),
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04 MST"),
ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"),
ExpiresInput: expiresInput,
NeverExpires: never,
MaxDownloads: box.MaxDownloads,
DownloadCount: box.DownloadCount,
FileCount: len(box.Files),
TotalSize: helpers.FormatBytes(totalSize),
BackendID: a.uploadService.BoxStorageBackendID(box),
Protected: a.uploadService.IsProtected(box),
Obfuscated: box.Obfuscate,
},
},
})
}
func (a *App) AdminUpdateBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
boxID := r.PathValue("boxID")
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+read+form", http.StatusSeeOther)
return
}
var expiresAt time.Time
if r.FormValue("never_expires") == "on" {
expiresAt = time.Now().UTC().AddDate(100, 0, 0)
} else {
parsed, err := time.Parse("2006-01-02T15:04", strings.TrimSpace(r.FormValue("expires_at")))
if err != nil {
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Invalid+expiration+date", http.StatusSeeOther)
return
}
expiresAt = parsed.UTC()
}
maxDownloads := parsePositiveInt(r.FormValue("max_downloads"))
removePassword := r.FormValue("remove_password") == "on"
if err := a.uploadService.AdminUpdateBox(boxID, expiresAt, maxDownloads, removePassword); err != nil {
a.logger.Warn("admin box update failed", "source", "admin", "severity", "warn", "code", 4306, "box_id", boxID, "error", err.Error())
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+save+changes", http.StatusSeeOther)
return
}
a.logger.Info("admin box updated", "source", "admin", "severity", "user_activity", "code", 2306, "ip", uploadClientIP(r), "box_id", boxID)
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=Changes+saved", http.StatusSeeOther)
}
func (a *App) AdminDeleteBoxFile(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
boxID := r.PathValue("boxID")
fileID := r.PathValue("fileID")
boxDeleted, err := a.uploadService.RemoveFileFromBox(boxID, fileID)
if err != nil {
a.logger.Warn("admin file delete failed", "source", "admin", "severity", "warn", "code", 4305, "box_id", boxID, "file_id", fileID, "error", err.Error())
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+remove+file", http.StatusSeeOther)
return
}
a.logger.Info("admin removed box file", "source", "admin", "severity", "user_activity", "code", 2305, "ip", uploadClientIP(r), "box_id", boxID, "file_id", fileID)
if boxDeleted {
http.Redirect(w, r, "/admin/files?notice=Box+deleted+(last+file+removed)", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=File+removed", http.StatusSeeOther)
}

View File

@@ -17,6 +17,7 @@ type apiDocsData struct {
ShareXExampleURL string ShareXExampleURL string
ShareXDownloadURL string ShareXDownloadURL string
ShareXFileFieldName string ShareXFileFieldName string
ShareXGroupWindow string
} }
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) { func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
@@ -33,6 +34,7 @@ func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload", ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu", ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
ShareXFileFieldName: "sharex", ShareXFileFieldName: "sharex",
ShareXGroupWindow: uploadGroupWindow.String(),
}, },
}) })
} }
@@ -47,11 +49,16 @@ func (a *App) ShareXAnonymousConfig(w http.ResponseWriter, r *http.Request) {
"RequestURL": a.cfg.BaseURL + "/api/v1/upload", "RequestURL": a.cfg.BaseURL + "/api/v1/upload",
"Headers": map[string]string{ "Headers": map[string]string{
"Accept": "application/json", "Accept": "application/json",
// Group a multi-file selection (sent as back-to-back requests) into
// one box. Remove this header for one box per file.
uploadBatchHeader: "sharex",
}, },
"Body": "MultipartFormData", "Body": "MultipartFormData",
"FileFormName": "sharex", "FileFormName": "sharex",
"URL": "$json:boxUrl$", "URL": "{json:boxUrl}",
"DeletionURL": "$json:manageUrl$", "ThumbnailURL": "{json:thumbnailUrl}",
"DeletionURL": "{json:deleteUrl}",
"ErrorMessage": "{json:error}",
}) })
} }
@@ -109,22 +116,24 @@ func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
"type": "object", "type": "object",
"required": []string{"boxId", "boxUrl", "zipUrl", "manageUrl", "deleteUrl", "expiresAt", "files"}, "required": []string{"boxId", "boxUrl", "zipUrl", "manageUrl", "deleteUrl", "expiresAt", "files"},
"properties": map[string]any{ "properties": map[string]any{
"boxId": map[string]any{"type": "string"}, "boxId": map[string]any{"type": "string"},
"boxUrl": map[string]any{"type": "string", "format": "uri"}, "boxUrl": map[string]any{"type": "string", "format": "uri"},
"zipUrl": map[string]any{"type": "string", "format": "uri"}, "zipUrl": map[string]any{"type": "string", "format": "uri"},
"manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."}, "thumbnailUrl": map[string]any{"type": "string", "format": "uri", "description": "Thumbnail of the most recently uploaded file (placeholder until generated)."},
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer POST URL for deleting this upload. Returned only at upload time."}, "manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."},
"expiresAt": map[string]any{"type": "string", "format": "date-time"}, "deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for deleting this upload (GET or POST). Returned only at upload time."},
"expiresAt": map[string]any{"type": "string", "format": "date-time"},
"files": map[string]any{ "files": map[string]any{
"type": "array", "type": "array",
"items": map[string]any{ "items": map[string]any{
"type": "object", "type": "object",
"required": []string{"id", "name", "size", "url"}, "required": []string{"id", "name", "size", "url"},
"properties": map[string]any{ "properties": map[string]any{
"id": map[string]any{"type": "string"}, "id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"}, "name": map[string]any{"type": "string"},
"size": map[string]any{"type": "string"}, "size": map[string]any{"type": "string"},
"url": map[string]any{"type": "string", "format": "uri"}, "url": map[string]any{"type": "string", "format": "uri"},
"thumbnailUrl": map[string]any{"type": "string", "format": "uri"},
}, },
}, },
}, },

View File

@@ -18,6 +18,7 @@ type App struct {
settingsService *services.SettingsService settingsService *services.SettingsService
banService *services.BanService banService *services.BanService
rateLimiter *rateLimiter 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, banService *services.BanService) *App {
@@ -30,6 +31,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
settingsService: settingsService, settingsService: settingsService,
banService: banService, banService: banService,
rateLimiter: newRateLimiter(), rateLimiter: newRateLimiter(),
uploadGroups: newUploadGrouper(),
} }
} }
@@ -94,7 +96,6 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage) mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage) mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest) mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest)
mux.HandleFunc("POST /admin/storage/{backendID}/disable", a.AdminDisableStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage) mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage)
mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup) mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup)
mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails) mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails)
@@ -107,11 +108,17 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy) mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy)
mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage) mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("GET /admin/boxes/{boxID}/edit", a.AdminEditBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/edit", a.AdminUpdateBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/files/{fileID}/delete", a.AdminDeleteBoxFile)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted) mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox) mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox) mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
// GET variant so ShareX (which issues a GET to the configured DeletionURL)
// can delete a box via its secret one-time delete token.
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox) mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip) mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)

View File

@@ -82,7 +82,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
FileCount: len(row.Box.Files), FileCount: len(row.Box.Files),
Size: row.TotalSizeLabel, Size: row.TotalSizeLabel,
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"), CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"), ExpiresAt: boxExpiryLabel(row.Box.ExpiresAt, "Jan 2 15:04"),
URL: "/d/" + row.Box.ID, URL: "/d/" + row.Box.ID,
}) })
} }

View File

@@ -78,7 +78,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
} }
} }
expiresLabel := box.ExpiresAt.Format("Jan 2, 2006 15:04 MST") expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox" title := "Shared files on Warpbox"
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel) description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
if locked && box.Obfuscate { if locked && box.Obfuscate {
@@ -337,6 +337,21 @@ func unlockCookieName(boxID string) string {
return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID) return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
} }
// neverExpires reports whether a box's expiry is far enough out to be treated as
// "forever" (set via the unlimited / -1 expiry option).
func neverExpires(t time.Time) bool {
return time.Until(t) > 50*365*24*time.Hour
}
// boxExpiryLabel formats a box's expiry with the given layout, rendering
// "forever" boxes as "Never" instead of a meaningless far-future date.
func boxExpiryLabel(t time.Time, layout string) string {
if neverExpires(t) {
return "Never"
}
return t.Format(layout)
}
func absoluteURL(r *http.Request, path string) string { func absoluteURL(r *http.Request, path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path return path

View File

@@ -82,7 +82,7 @@ func (a *App) managePageData(box services.Box, token string) managePageData {
Token: token, Token: token,
FileCount: len(box.Files), FileCount: len(box.Files),
TotalSize: helpers.FormatBytes(totalSize), TotalSize: helpers.FormatBytes(totalSize),
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"), ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"),
DownloadCount: box.DownloadCount, DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads, MaxDownloads: box.MaxDownloads,
Protected: a.uploadService.IsProtected(box), Protected: a.uploadService.IsProtected(box),

View File

@@ -75,6 +75,10 @@ func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user ser
unlimited = true unlimited = true
case loggedIn: case loggedIn:
maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays
// A negative per-user MaxDays override means unlimited retention.
if maxDays < 0 {
unlimited = true
}
} }
return buildExpiryOptions(maxDays, unlimited) return buildExpiryOptions(maxDays, unlimited)
} }
@@ -103,6 +107,10 @@ func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
if len(options) == 0 { if len(options) == 0 {
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)}) options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
} }
// Unlimited uploaders can pick "never expires" (sentinel -1) after the ladder.
if unlimited {
options = append(options, expiryOption{Minutes: -1, Label: "Unlimited (never expires)"})
}
// Default to 24h when available, otherwise the smallest option offered. // Default to 24h when available, otherwise the smallest option offered.
defaultMinutes := options[0].Minutes defaultMinutes := options[0].Minutes
@@ -154,5 +162,9 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
if policy.StorageQuotaSet { if policy.StorageQuotaSet {
quota = services.FormatMegabytesLabel(policy.StorageQuotaMB) quota = services.FormatMegabytesLabel(policy.StorageQuotaMB)
} }
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max." expiryLimit := strconv.Itoa(policy.MaxDays) + " day max."
if policy.MaxDays < 0 {
expiryLimit = "no expiry limit."
}
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
} }

View File

@@ -36,7 +36,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
} }
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn) effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
rateKey := uploadRateKey(r, user, loggedIn) rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) { if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID) a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID)
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down") helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return return
@@ -77,22 +77,39 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
maxDays := parseInt(r.FormValue("max_days")) // Unlimited expiry: admins, or users whose effective MaxDays is negative.
unlimitedExpiry := isAdminUpload || effectivePolicy.MaxDays < 0
rawMaxDays := parseInt(r.FormValue("max_days"))
maxDays := rawMaxDays
if maxDays <= 0 { if maxDays <= 0 {
maxDays = min(7, effectivePolicy.MaxDays) maxDays = 7
if effectivePolicy.MaxDays > 0 && effectivePolicy.MaxDays < maxDays {
maxDays = effectivePolicy.MaxDays
}
} }
if !isAdminUpload && maxDays > effectivePolicy.MaxDays { if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays) a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return return
} }
expiresMinutes := parseInt(r.FormValue("expires_minutes")) expiresMinutes := parseInt(r.FormValue("expires_minutes"))
if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 { // A negative expires_minutes (or max_days) is the "never expires" 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)
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", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return return
} }
result, err := a.uploadService.CreateBox(files, services.UploadOptions{ opts := services.UploadOptions{
MaxDays: maxDays, MaxDays: maxDays,
ExpiresInMinutes: expiresMinutes, ExpiresInMinutes: expiresMinutes,
MaxDownloads: parseInt(r.FormValue("max_downloads")), MaxDownloads: parseInt(r.FormValue("max_downloads")),
@@ -103,14 +120,20 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0, SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0,
CreatorIP: uploadClientIP(r), CreatorIP: uploadClientIP(r),
StorageBackendID: effectivePolicy.StorageBackendID, StorageBackendID: effectivePolicy.StorageBackendID,
}) }
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))
helpers.WriteJSONError(w, status, policyMessage)
return
}
if err != nil { if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error()) a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error()) helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return return
} }
if !isAdminUpload { if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil { if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, boxesAdded); err != nil {
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error()) a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
} }
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil { if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
@@ -130,6 +153,77 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, result.BoxURL) _, _ = fmt.Fprintln(w, result.BoxURL)
} }
// createOrAppendBox creates a new box. It only ever appends to an existing box
// when the request opts in via the X-Warpbox-Batch header: requests sharing the
// same batch value (per account, or per IP for anonymous) within
// uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
if batch == "" {
if enforceBoxLimits {
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
return result, 1, 0, "", nil
}
// Group key is scoped to the uploader so batches never cross accounts/IPs.
identity := "ip:" + uploadClientIP(r)
if loggedIn {
identity = "user:" + user.ID
}
entry := a.uploadGroups.entryFor(identity + "|" + batch)
// Hold the per-key lock across the whole create/append so concurrent batched
// uploads serialise into the same box instead of racing.
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
// Re-attach the manage/delete URLs from the box's creation so every
// upload in the batch returns a working deletion URL.
result.ManageURL = entry.manageURL
result.DeleteURL = entry.deleteURL
entry.at = time.Now()
return result, 0, 0, "", nil
}
}
}
if enforceBoxLimits {
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
entry.boxID = result.BoxID
entry.manageURL = result.ManageURL
entry.deleteURL = result.DeleteURL
entry.at = time.Now()
return result, 1, 0, "", nil
}
// batchBoxMatches guards that a batched append only ever touches a box owned by
// the same uploader (account for logged-in users, creator IP for anonymous).
func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn bool, r *http.Request) bool {
if loggedIn {
return box.OwnerID == user.ID
}
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
}
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) { func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
if len(files) == 0 { if len(files) == 0 {
return 0, "" return 0, ""
@@ -151,16 +245,6 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "anonymous daily upload limit reached" return http.StatusTooManyRequests, "anonymous daily upload limit reached"
} }
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "anonymous daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "anonymous active box limit reached"
}
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" { if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
return status, message return status, message
} }
@@ -174,16 +258,6 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "daily upload limit reached" return http.StatusTooManyRequests, "daily upload limit reached"
} }
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "active box limit reached"
}
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID) activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil { if err != nil {
return http.StatusInternalServerError, "storage quota could not be checked" return http.StatusInternalServerError, "storage quota could not be checked"
@@ -197,6 +271,42 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
return 0, "" return 0, ""
} }
func (a *App) checkBoxCreationPolicy(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy) (int, string) {
now := time.Now().UTC()
if !loggedIn {
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "anonymous daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "anonymous active box limit reached"
}
return 0, ""
}
usage, err := a.settingsService.UsageForUser(user.ID, now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "active box limit reached"
}
return 0, ""
}
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error { func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
now := time.Now().UTC() now := time.Now().UTC()
if loggedIn { if loggedIn {

View File

@@ -0,0 +1,76 @@
package handlers
import (
"sync"
"time"
)
// uploadGroupWindow is how long after a batched upload a follow-up upload with
// the same X-Warpbox-Batch value (and same account/IP) is folded into the same
// box. ShareX sends a multi-file selection as separate back-to-back requests;
// the batch header lets it land them in one box.
const uploadGroupWindow = 20 * time.Second
// uploadBatchHeader is the opt-in request header. Without it, uploads behave
// exactly as before (one box per request). With it, requests sharing the same
// value (per account/IP) within uploadGroupWindow are grouped into one box.
const uploadBatchHeader = "X-Warpbox-Batch"
// uploadGroupPruneInterval is how often entryFor drops stale entries so the map
// can't grow without bound (one key per account/IP + batch value otherwise).
const uploadGroupPruneInterval = 5 * time.Minute
// uploadGrouper tracks the most recent box per batch key so opt-in batched
// uploads land in a single box. Each key has its own lock, which also serialises
// that key's concurrent uploads so they append to the same box instead of racing
// to create several.
type uploadGrouper struct {
mu sync.Mutex
entries map[string]*uploadGroupEntry
lastPrune time.Time
}
type uploadGroupEntry struct {
mu sync.Mutex
boxID string
manageURL string
deleteURL string
at time.Time
}
func newUploadGrouper() *uploadGrouper {
return &uploadGrouper{entries: make(map[string]*uploadGroupEntry)}
}
func (g *uploadGrouper) entryFor(key string) *uploadGroupEntry {
g.mu.Lock()
defer g.mu.Unlock()
g.pruneLocked(time.Now())
entry, ok := g.entries[key]
if !ok {
entry = &uploadGroupEntry{at: time.Now()}
g.entries[key] = entry
}
return entry
}
// pruneLocked drops entries whose last use is well past the grouping window so
// the map stays bounded to recently-active keys. Callers must hold g.mu. Entries
// currently in use are kept to avoid removing one a request is about to
// populate.
func (g *uploadGrouper) pruneLocked(now time.Time) {
if now.Sub(g.lastPrune) < uploadGroupPruneInterval {
return
}
g.lastPrune = now
for key, entry := range g.entries {
if !entry.mu.TryLock() {
continue
}
stale := now.Sub(entry.at) > 2*uploadGroupWindow
entry.mu.Unlock()
if stale {
delete(g.entries, key)
}
}
}

View File

@@ -0,0 +1,24 @@
package handlers
import (
"testing"
"time"
)
func TestUploadGroupPrunesFailedEntries(t *testing.T) {
g := newUploadGrouper()
entry := g.entryFor("ip:203.0.113.1|failed")
entry.mu.Lock()
entry.at = time.Now().Add(-3 * uploadGroupWindow)
entry.mu.Unlock()
g.lastPrune = time.Now().Add(-uploadGroupPruneInterval)
_ = g.entryFor("ip:203.0.113.1|next")
if _, ok := g.entries["ip:203.0.113.1|failed"]; ok {
t.Fatalf("stale failed entry was not pruned")
}
if _, ok := g.entries["ip:203.0.113.1|next"]; !ok {
t.Fatalf("new entry was not created")
}
}

View File

@@ -49,6 +49,7 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
middleware.RequestID, middleware.RequestID,
middleware.SecurityHeaders, middleware.SecurityHeaders,
middleware.Gzip, middleware.Gzip,
middleware.ClientIP(cfg.TrustedProxies),
middleware.Logger(logger), middleware.Logger(logger),
middleware.Bans(logger, banService, cfg.TrustedProxies), middleware.Bans(logger, banService, cfg.TrustedProxies),
) )

View File

@@ -11,11 +11,15 @@ import (
func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware { func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies) ip, ok := services.ClientIPFromContext(r)
r = services.WithClientIP(r, ip) if !ok {
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
r = services.WithClientIP(r, ip)
}
now := time.Now().UTC() now := time.Now().UTC()
protectedProxy := services.IsProtectedProxyIP(ip, trustedProxies)
if bans != nil { if bans != nil && !protectedProxy {
if matched, ok, err := bans.Match(ip, now); err != nil { if matched, ok, err := bans.Match(ip, now); err != nil {
logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error()) logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error())
} else if ok { } else if ok {
@@ -26,10 +30,20 @@ func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []strin
return return
} }
settings, err := bans.Settings()
if err != nil {
logger.Error("ban settings load failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "error", err.Error())
next.ServeHTTP(w, r)
return
}
if !settings.AutoBanEnabled {
next.ServeHTTP(w, r)
return
}
if pattern, err := bans.MaliciousPattern(r.URL.Path); err != nil { if pattern, err := bans.MaliciousPattern(r.URL.Path); err != nil {
logger.Error("malicious path check failed", "source", "ban", "severity", "error", "code", 5002, "ip", ip, "error", err.Error()) logger.Error("malicious path check failed", "source", "ban", "severity", "error", "code", 5002, "ip", ip, "error", err.Error())
} else if pattern != "" { } else if pattern != "" {
if result, err := bans.RecordAbuse(ip, services.AbuseKindMaliciousPath, r.URL.Path, banThreshold(bans, services.AbuseKindMaliciousPath), now); err != nil { if result, err := bans.RecordAbuse(ip, services.AbuseKindMaliciousPath, r.URL.Path, settings.MaliciousPathThreshold, now); err != nil {
logger.Error("malicious path event failed", "source", "ban", "severity", "error", "code", 5003, "ip", ip, "path", r.URL.Path, "error", err.Error()) logger.Error("malicious path event failed", "source", "ban", "severity", "error", "code", 5003, "ip", ip, "path", r.URL.Path, "error", err.Error())
} else if result.Enabled { } else if result.Enabled {
logger.Warn("malicious path requested", "source", "ban", "severity", "warn", "code", 4302, "ip", ip, "path", r.URL.Path, "pattern", pattern, "count", result.Event.Count) logger.Warn("malicious path requested", "source", "ban", "severity", "warn", "code", 4302, "ip", ip, "path", r.URL.Path, "pattern", pattern, "count", result.Event.Count)
@@ -48,18 +62,3 @@ func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []strin
}) })
} }
} }
func banThreshold(bans *services.BanService, kind string) int {
settings, err := bans.Settings()
if err != nil {
return 0
}
switch kind {
case services.AbuseKindAdminLogin:
return settings.AdminLoginFailureThreshold
case services.AbuseKindUserLogin:
return settings.UserLoginFailureThreshold
default:
return settings.MaliciousPathThreshold
}
}

View File

@@ -79,6 +79,75 @@ func TestBansMiddlewareAutoBansMaliciousPaths(t *testing.T) {
} }
} }
func TestBansMiddlewareSkipsAutoBanWhenDisabled(t *testing.T) {
bans := newMiddlewareBanService(t)
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
for i := 0; i < 5; i++ {
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
request.RemoteAddr = "203.0.113.23:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code == http.StatusForbidden {
t.Fatalf("request %d was blocked while auto-ban disabled", i+1)
}
}
if _, ok, err := bans.Match("203.0.113.23", time.Now().UTC()); err != nil || ok {
t.Fatalf("disabled auto-ban Match = %v, %v", ok, err)
}
}
func TestBansMiddlewareDoesNotBlockProtectedProxyIP(t *testing.T) {
bans := newMiddlewareBanService(t)
if _, err := bans.CreateManualBan("127.0.0.1", "bad historical ban", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
t.Fatalf("CreateManualBan returned error: %v", err)
}
called := false
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = io.WriteString(w, "ok")
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.RemoteAddr = "127.0.0.1:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if !called || response.Code != http.StatusOK {
t.Fatalf("protected proxy response = called %v code %d", called, response.Code)
}
}
func TestBansMiddlewareDoesNotAutoBanProtectedProxyIP(t *testing.T) {
bans := newMiddlewareBanService(t)
settings, err := bans.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
settings.MaliciousPathThreshold = 1
if err := bans.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
request.RemoteAddr = "127.0.0.1:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code == http.StatusForbidden {
t.Fatalf("protected proxy was auto-banned")
}
if _, ok, err := bans.Match("127.0.0.1", time.Now().UTC()); err != nil || ok {
t.Fatalf("protected proxy Match = %v, %v", ok, err)
}
}
func newMiddlewareBanService(t *testing.T) *services.BanService { func newMiddlewareBanService(t *testing.T) *services.BanService {
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()

View File

@@ -0,0 +1,16 @@
package middleware
import (
"net/http"
"warpbox.dev/backend/libs/services"
)
func ClientIP(trustedProxies []string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
next.ServeHTTP(w, services.WithClientIP(r, ip))
})
}
}

View File

@@ -4,6 +4,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
"warpbox.dev/backend/libs/services"
) )
type statusRecorder struct { type statusRecorder struct {
@@ -38,6 +40,10 @@ func Logger(logger *slog.Logger) Middleware {
if status == 0 { if status == 0 {
status = http.StatusOK 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", logger.Info("http request",
"source", "http", "source", "http",
@@ -49,6 +55,7 @@ func Logger(logger *slog.Logger) Middleware {
"bytes", recorder.bytes, "bytes", recorder.bytes,
"duration_ms", time.Since(start).Milliseconds(), "duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()), "request_id", RequestIDFromContext(r.Context()),
"ip", ip,
"remote_addr", r.RemoteAddr, "remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(), "user_agent", r.UserAgent(),
) )

View File

@@ -574,6 +574,38 @@ func (s *AuthService) SetUserStorageBackend(userID, backendID string) error {
return s.saveUser(user) return s.saveUser(user)
} }
func (s *AuthService) ClearStorageBackendOverrides(backendID string) (int, error) {
backendID = strings.TrimSpace(backendID)
if backendID == "" {
return 0, nil
}
cleared := 0
err := s.db.Update(func(tx *bbolt.Tx) error {
users := tx.Bucket(usersBucket)
return users.ForEach(func(key, value []byte) error {
var user User
if err := json.Unmarshal(value, &user); err != nil {
return err
}
if user.Policy.StorageBackendID == nil || *user.Policy.StorageBackendID != backendID {
return nil
}
user.Policy.StorageBackendID = nil
user.UpdatedAt = time.Now().UTC()
next, err := json.Marshal(user)
if err != nil {
return err
}
if err := users.Put(key, next); err != nil {
return err
}
cleared++
return nil
})
})
return cleared, err
}
func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) { func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) {
if err := validateUserPolicy(policy); err != nil { if err := validateUserPolicy(policy); err != nil {
return User{}, err return User{}, err
@@ -862,20 +894,20 @@ func validateUserPolicy(policy UserPolicy) error {
if policy.DailyUploadMB != nil && ((*policy.DailyUploadMB < 0 && *policy.DailyUploadMB != -1) || *policy.DailyUploadMB == 0) { if policy.DailyUploadMB != nil && ((*policy.DailyUploadMB < 0 && *policy.DailyUploadMB != -1) || *policy.DailyUploadMB == 0) {
return fmt.Errorf("daily upload override must be positive or -1 for unlimited") return fmt.Errorf("daily upload override must be positive or -1 for unlimited")
} }
if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 { if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 && *policy.StorageQuotaMB != -1 {
return fmt.Errorf("storage quota override cannot be negative") return fmt.Errorf("storage quota override must be 0/positive or -1 for unlimited")
} }
if policy.MaxDays != nil && *policy.MaxDays <= 0 { if policy.MaxDays != nil && *policy.MaxDays <= 0 && *policy.MaxDays != -1 {
return fmt.Errorf("expiration override must be positive") return fmt.Errorf("expiration override must be positive or -1 for unlimited")
} }
if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 { if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 && *policy.DailyBoxes != -1 {
return fmt.Errorf("daily box override must be positive") return fmt.Errorf("daily box override must be positive or -1 for unlimited")
} }
if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 { if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 && *policy.ActiveBoxes != -1 {
return fmt.Errorf("active box override must be positive") return fmt.Errorf("active box override must be positive or -1 for unlimited")
} }
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 { if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 && *policy.ShortWindowRequests != -1 {
return fmt.Errorf("short-window request override must be positive") return fmt.Errorf("short-window request override must be positive or -1 for unlimited")
} }
return nil return nil
} }

View File

@@ -312,7 +312,11 @@ func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) {
} }
now = now.UTC() now = now.UTC()
var matched BanRecord var matched BanRecord
err := s.db.Update(func(tx *bbolt.Tx) error { var matchedKey []byte
// Read-only scan first: the common case (no match) only takes a concurrent
// read transaction, instead of grabbing the single bbolt write lock on every
// request that flows through the ban middleware.
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bansBucket) bucket := tx.Bucket(bansBucket)
return bucket.ForEach(func(key, value []byte) error { return bucket.ForEach(func(key, value []byte) error {
if matched.ID != "" { if matched.ID != "" {
@@ -325,20 +329,37 @@ func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) {
if !record.Active(now) || !banTargetMatches(record.Normalized, parsed) { if !record.Active(now) || !banTargetMatches(record.Normalized, parsed) {
return nil return nil
} }
record.LastMatchedAt = &now
record.UpdatedAt = now
next, err := json.Marshal(record)
if err != nil {
return err
}
if err := bucket.Put(key, next); err != nil {
return err
}
matched = record matched = record
matchedKey = append([]byte(nil), key...) // key bytes are only valid within the txn
return nil return nil
}) })
}) })
return MatchedBan{Ban: matched, IP: ip}, matched.ID != "", err if err != nil || matched.ID == "" {
return MatchedBan{Ban: matched, IP: ip}, matched.ID != "", err
}
// On a hit, record the match time in a short write transaction.
matched.LastMatchedAt = &now
matched.UpdatedAt = now
_ = s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bansBucket)
data := bucket.Get(matchedKey)
if data == nil {
return nil
}
var record BanRecord
if err := json.Unmarshal(data, &record); err != nil {
return nil
}
record.LastMatchedAt = &now
record.UpdatedAt = now
next, err := json.Marshal(record)
if err != nil {
return nil
}
return bucket.Put(matchedKey, next)
})
return MatchedBan{Ban: matched, IP: ip}, true, nil
} }
func (r BanRecord) Active(now time.Time) bool { func (r BanRecord) Active(now time.Time) bool {
@@ -498,6 +519,11 @@ func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now tim
if err != nil || !triggered { if err != nil || !triggered {
return AbuseResult{Event: event, Triggered: false, Enabled: true}, err return AbuseResult{Event: event, Triggered: false, Enabled: true}, err
} }
if matched, ok, err := s.Match(ip, now); err != nil {
return AbuseResult{}, err
} else if ok {
return AbuseResult{Event: event, Ban: matched.Ban, Triggered: true, Enabled: true}, nil
}
reason := fmt.Sprintf("%s threshold reached: %s", strings.ReplaceAll(kind, "_", " "), detail) reason := fmt.Sprintf("%s threshold reached: %s", strings.ReplaceAll(kind, "_", " "), detail)
ban, err = s.createBan(ip, reason, BanSourceAuto, "", now.Add(time.Duration(settings.AutoBanDurationHours)*time.Hour), now) ban, err = s.createBan(ip, reason, BanSourceAuto, "", now.Add(time.Duration(settings.AutoBanDurationHours)*time.Hour), now)
if err != nil { if err != nil {

View File

@@ -79,6 +79,17 @@ func TestBanServiceAutoBanThresholdsAndDisabled(t *testing.T) {
if err != nil || !result.Triggered || result.Ban.ID == "" { if err != nil || !result.Triggered || result.Ban.ID == "" {
t.Fatalf("RecordAbuse threshold = %+v, %v", result, err) t.Fatalf("RecordAbuse threshold = %+v, %v", result, err)
} }
again, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(4*time.Minute))
if err != nil || !again.Triggered || again.Ban.ID != result.Ban.ID {
t.Fatalf("RecordAbuse duplicate = %+v, %v", again, err)
}
records, err := bans.ListBans()
if err != nil {
t.Fatalf("ListBans returned error: %v", err)
}
if len(records) != 1 {
t.Fatalf("ban count = %d, want 1", len(records))
}
} }
func TestBanServiceMaliciousPathRules(t *testing.T) { func TestBanServiceMaliciousPathRules(t *testing.T) {

View File

@@ -21,19 +21,19 @@ func ClientIPFromContext(r *http.Request) (string, bool) {
// ClientIP resolves the effective client IP. When trustedProxies is empty, // ClientIP resolves the effective client IP. When trustedProxies is empty,
// forwarded headers are trusted for easy reverse-proxy/container defaults. // forwarded headers are trusted for easy reverse-proxy/container defaults.
func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string { func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string {
remoteIP := remoteIPOnly(remoteAddr) remoteIP := IPOnly(remoteAddr)
if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) { if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) {
if ip := firstForwardedIP(forwardedFor); ip != "" { if ip := firstForwardedIP(forwardedFor); ip != "" {
return ip return IPOnly(ip)
} }
if ip := strings.TrimSpace(realIP); ip != "" { if ip := strings.TrimSpace(realIP); ip != "" {
return ip return IPOnly(ip)
} }
} }
return remoteIP return remoteIP
} }
func remoteIPOnly(remoteAddr string) string { func IPOnly(remoteAddr string) string {
host := strings.TrimSpace(remoteAddr) host := strings.TrimSpace(remoteAddr)
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil { if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = splitHost host = splitHost
@@ -41,14 +41,65 @@ func remoteIPOnly(remoteAddr string) string {
return strings.Trim(host, "[]") return strings.Trim(host, "[]")
} }
func firstForwardedIP(forwardedFor string) string { func IsProtectedProxyIP(ip string, trustedProxies []string) bool {
for _, part := range strings.Split(forwardedFor, ",") { parsed := net.ParseIP(IPOnly(ip))
ip := strings.TrimSpace(part) if parsed == nil {
if ip != "" { return false
return strings.Trim(ip, "[]") }
if parsed.IsLoopback() {
return true
}
return remoteTrusted(parsed.String(), trustedProxies)
}
func ProtectedBanTarget(target string, trustedProxies []string) bool {
normalized, err := NormalizeBanTarget(target)
if err != nil {
return false
}
if !strings.Contains(normalized, "/") {
return IsProtectedProxyIP(normalized, trustedProxies)
}
_, targetNet, err := net.ParseCIDR(normalized)
if err != nil {
return false
}
if targetNet.Contains(net.ParseIP("127.0.0.1")) || targetNet.Contains(net.ParseIP("::1")) {
return true
}
for _, trusted := range trustedProxies {
trusted = strings.TrimSpace(trusted)
if trusted == "" {
continue
}
if strings.Contains(trusted, "/") {
if _, trustedNet, err := net.ParseCIDR(trusted); err == nil && networksOverlap(targetNet, trustedNet) {
return true
}
continue
}
if ip := net.ParseIP(IPOnly(trusted)); ip != nil && targetNet.Contains(ip) {
return true
} }
} }
return "" return false
}
func firstForwardedIP(forwardedFor string) string {
var fallback string
for _, part := range strings.Split(forwardedFor, ",") {
ip := IPOnly(part)
if net.ParseIP(ip) == nil {
continue
}
if fallback == "" {
fallback = ip
}
if isExternalIP(ip) {
return ip
}
}
return fallback
} }
func remoteTrusted(remoteIP string, trustedProxies []string) bool { func remoteTrusted(remoteIP string, trustedProxies []string) bool {
@@ -73,3 +124,17 @@ func remoteTrusted(remoteIP string, trustedProxies []string) bool {
} }
return false return false
} }
func isExternalIP(ip string) bool {
parsed := net.ParseIP(IPOnly(ip))
return parsed != nil &&
!parsed.IsLoopback() &&
!parsed.IsPrivate() &&
!parsed.IsLinkLocalUnicast() &&
!parsed.IsLinkLocalMulticast() &&
!parsed.IsUnspecified()
}
func networksOverlap(a, b *net.IPNet) bool {
return a.Contains(b.IP) || b.Contains(a.IP)
}

View File

@@ -27,3 +27,48 @@ func TestClientIPFallsBackToRealIP(t *testing.T) {
t.Fatalf("ClientIP = %q, want real IP", ip) t.Fatalf("ClientIP = %q, want real IP", ip)
} }
} }
func TestClientIPStripsPortsFromForwardedHeaders(t *testing.T) {
ip := ClientIP("127.0.0.1:6070", "203.0.113.15:49152", "", nil)
if ip != "203.0.113.15" {
t.Fatalf("ClientIP = %q, want forwarded IP without port", ip)
}
}
func TestClientIPPrefersExternalForwardedAddress(t *testing.T) {
ip := ClientIP("127.0.0.1:6070", "172.30.0.1, 198.51.100.30", "", nil)
if ip != "198.51.100.30" {
t.Fatalf("ClientIP = %q, want public forwarded IP", ip)
}
}
func TestIPOnlyHandlesIPv6HostPort(t *testing.T) {
ip := IPOnly("[2001:db8::1]:6070")
if ip != "2001:db8::1" {
t.Fatalf("IPOnly = %q, want IPv6 address without port", ip)
}
}
func TestProtectedProxyIP(t *testing.T) {
trusted := []string{"127.0.0.1", "172.30.0.1", "10.88.0.0/16"}
for _, ip := range []string{"127.0.0.1:48122", "172.30.0.1", "10.88.0.12"} {
if !IsProtectedProxyIP(ip, trusted) {
t.Fatalf("IsProtectedProxyIP(%q) = false, want true", ip)
}
}
if IsProtectedProxyIP("203.0.113.50", trusted) {
t.Fatalf("external IP treated as protected")
}
}
func TestProtectedBanTarget(t *testing.T) {
trusted := []string{"172.30.0.1", "10.88.0.0/16"}
for _, target := range []string{"127.0.0.1", "172.30.0.1", "172.30.0.0/24", "10.88.12.0/24"} {
if !ProtectedBanTarget(target, trusted) {
t.Fatalf("ProtectedBanTarget(%q) = false, want true", target)
}
}
if ProtectedBanTarget("203.0.113.0/24", trusted) {
t.Fatalf("external target treated as protected")
}
}

View File

@@ -233,6 +233,29 @@ func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) erro
}) })
} }
func (s *SettingsService) ResetStorageBackend(backendID string) (bool, bool, error) {
backendID = strings.TrimSpace(backendID)
if backendID == "" || backendID == StorageBackendLocal {
return false, false, nil
}
settings, err := s.UploadPolicy()
if err != nil {
return false, false, err
}
resetAnonymous := settings.AnonymousStorageBackend == backendID
resetUser := settings.UserStorageBackend == backendID
if !resetAnonymous && !resetUser {
return false, false, nil
}
if resetAnonymous {
settings.AnonymousStorageBackend = StorageBackendLocal
}
if resetUser {
settings.UserStorageBackend = StorageBackendLocal
}
return resetAnonymous, resetUser, s.UpdateUploadPolicy(settings)
}
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) { func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
key := usageKey(subjectType, subject, now) key := usageKey(subjectType, subject, now)
var record UsageRecord var record UsageRecord

View File

@@ -86,6 +86,7 @@ type StorageBackendView struct {
UsageBytes int64 UsageBytes int64
UsageLabel string UsageLabel string
InUse bool InUse bool
InUseReason string
SpeedTests []StorageSpeedTest SpeedTests []StorageSpeedTest
CanSpeedTest bool CanSpeedTest bool
} }
@@ -132,6 +133,14 @@ func (s *StorageService) Backend(id string) (StorageBackend, error) {
return s.backendFromConfig(cfg) return s.backendFromConfig(cfg)
} }
func (s *StorageService) BackendForMaintenance(id string) (StorageBackend, error) {
cfg, err := s.BackendConfig(id)
if err != nil {
return nil, err
}
return s.backendFromConfig(cfg)
}
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) { func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" || id == StorageBackendLocal { if id == "" || id == StorageBackendLocal {
@@ -340,21 +349,6 @@ func (s *StorageService) SaveBackendConfig(cfg StorageBackendConfig) error {
}) })
} }
func (s *StorageService) DisableBackend(id string, inUse bool) error {
if id == "" || id == StorageBackendLocal {
return fmt.Errorf("local storage cannot be disabled")
}
if inUse {
return fmt.Errorf("storage backend is in use")
}
cfg, err := s.BackendConfig(id)
if err != nil {
return err
}
cfg.Enabled = false
return s.SaveBackendConfig(cfg)
}
func (s *StorageService) DeleteBackend(id string, inUse bool) error { func (s *StorageService) DeleteBackend(id string, inUse bool) error {
if id == "" || id == StorageBackendLocal { if id == "" || id == StorageBackendLocal {
return fmt.Errorf("local storage cannot be deleted") return fmt.Errorf("local storage cannot be deleted")

View File

@@ -82,20 +82,22 @@ type File struct {
} }
type UploadResult struct { type UploadResult struct {
BoxID string `json:"boxId"` BoxID string `json:"boxId"`
BoxURL string `json:"boxUrl"` BoxURL string `json:"boxUrl"`
ZipURL string `json:"zipUrl"` ZipURL string `json:"zipUrl"`
ManageURL string `json:"manageUrl"` ThumbnailURL string `json:"thumbnailUrl"`
DeleteURL string `json:"deleteUrl"` ManageURL string `json:"manageUrl"`
ExpiresAt string `json:"expiresAt"` DeleteURL string `json:"deleteUrl"`
Files []ResultFile `json:"files"` ExpiresAt string `json:"expiresAt"`
Files []ResultFile `json:"files"`
} }
type ResultFile struct { type ResultFile struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Size string `json:"size"` Size string `json:"size"`
URL string `json:"url"` URL string `json:"url"`
ThumbnailURL string `json:"thumbnailUrl"`
} }
type AdminStats struct { type AdminStats struct {
@@ -196,14 +198,21 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
if len(files) == 0 { if len(files) == 0 {
return UploadResult{}, fmt.Errorf("no files were uploaded") return UploadResult{}, fmt.Errorf("no files were uploaded")
} }
if opts.MaxDays <= 0 {
opts.MaxDays = 7
}
now := time.Now().UTC() now := time.Now().UTC()
expiresAt := now.Add(time.Duration(opts.MaxDays) * 24 * time.Hour) var expiresAt time.Time
if opts.ExpiresInMinutes > 0 { switch {
case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0:
// "Forever" — a date far enough out that the box effectively never
// expires. No schema change; CanDownload/cleanup keep working as-is.
expiresAt = now.AddDate(100, 0, 0)
case opts.ExpiresInMinutes > 0:
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute) expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
default:
days := opts.MaxDays
if days <= 0 {
days = 7
}
expiresAt = now.Add(time.Duration(days) * 24 * time.Hour)
} }
box := Box{ box := Box{
@@ -226,15 +235,66 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
box.PasswordHash = hash box.PasswordHash = hash
} }
backend, err := s.storage.Backend(box.StorageBackendID) if err := s.writeFilesToBox(&box, files, opts); err != nil {
return UploadResult{}, err
}
if err := s.SaveBox(box); err != nil {
return UploadResult{}, err
}
s.logger.Info("upload complete",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"file_count", len(box.Files),
)
return s.resultForBox(box, deleteToken), nil
}
// AppendFiles adds files to an existing box (used to group a ShareX multi-file
// selection into a single box). The box keeps its original expiry, password and
// other settings; only the new files are written.
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
if len(files) == 0 {
return UploadResult{}, fmt.Errorf("no files were uploaded")
}
box, err := s.GetBox(boxID)
if err != nil { if err != nil {
return UploadResult{}, err return UploadResult{}, err
} }
if err := s.writeFilesToBox(&box, files, opts); err != nil {
return UploadResult{}, err
}
if err := s.SaveBox(box); err != nil {
return UploadResult{}, err
}
s.logger.Info("upload appended",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"added", len(files),
"file_count", len(box.Files),
)
return s.resultForBox(box, ""), nil
}
// writeFilesToBox streams each uploaded file into the box's storage backend and
// appends the file metadata to box.Files. The box's StorageBackendID determines
// where files land, so it works for both new and existing boxes.
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
backend, err := s.storage.Backend(box.StorageBackendID)
if err != nil {
return err
}
for _, header := range files { for _, header := range files {
if !opts.SkipSizeLimit { if !opts.SkipSizeLimit {
if err := s.ValidateSize(header.Size); err != nil { if err := s.ValidateSize(header.Size); err != nil {
return UploadResult{}, err return err
} }
} }
@@ -245,7 +305,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
file, err := header.Open() file, err := header.Open()
if err != nil { if err != nil {
return UploadResult{}, err return err
} }
fileID := randomID(8) fileID := randomID(8)
@@ -263,7 +323,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil { if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
file.Close() file.Close()
return UploadResult{}, err return err
} }
file.Close() file.Close()
@@ -278,20 +338,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
UploadedAt: time.Now().UTC(), UploadedAt: time.Now().UTC(),
}) })
} }
return nil
if err := s.SaveBox(box); err != nil {
return UploadResult{}, err
}
s.logger.Info("upload complete",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"file_count", len(box.Files),
)
return s.resultForBox(box, deleteToken), nil
} }
func (s *UploadService) GetBox(id string) (Box, error) { func (s *UploadService) GetBox(id string) (Box, error) {
@@ -506,6 +553,28 @@ func (s *UploadService) DeleteBox(boxID string) error {
return s.DeleteBoxWithSource(boxID, "admin") return s.DeleteBoxWithSource(boxID, "admin")
} }
func (s *UploadService) DeleteBoxesForStorageBackend(backendID, source string) (int, error) {
backendID = normalizeBackendID(backendID)
if backendID == StorageBackendLocal {
return 0, fmt.Errorf("local storage cannot be deleted")
}
boxes, err := s.ListBoxes(0)
if err != nil {
return 0, err
}
deleted := 0
for _, box := range boxes {
if s.BoxStorageBackendID(box) != backendID {
continue
}
if err := s.DeleteBoxWithSource(box.ID, source); err != nil {
return deleted, err
}
deleted++
}
return deleted, nil
}
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error { func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
box, err := s.GetBox(boxID) box, err := s.GetBox(boxID)
if err != nil { if err != nil {
@@ -525,7 +594,12 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
return err return err
} }
if box.ID != "" { if box.ID != "" {
if backend, err := s.storage.Backend(s.BoxStorageBackendID(box)); err == nil { backendID := s.BoxStorageBackendID(box)
backend, err := s.storage.Backend(backendID)
if err != nil {
backend, err = s.storage.BackendForMaintenance(backendID)
}
if err == nil {
if err := backend.DeletePrefix(context.Background(), box.ID); err != nil { if err := backend.DeletePrefix(context.Background(), box.ID); err != nil {
return err return err
} }
@@ -539,6 +613,80 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
return nil return nil
} }
// RemoveFileFromBox deletes a single file's stored objects (and thumbnail) and
// removes it from the box. If it was the box's last file, the whole box is
// deleted. Returns whether the box itself was removed.
func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
box, err := s.GetBox(boxID)
if err != nil {
return false, err
}
index := -1
for i, file := range box.Files {
if file.ID == fileID {
index = i
break
}
}
if index < 0 {
return false, os.ErrNotExist
}
file := box.Files[index]
backendID := s.BoxStorageBackendID(box)
backend, err := s.storage.Backend(backendID)
if err != nil {
backend, err = s.storage.BackendForMaintenance(backendID)
}
if err == nil {
if key := s.FileObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
if key := s.ThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
}
box.Files = append(box.Files[:index], box.Files[index+1:]...)
if len(box.Files) == 0 {
if err := s.DeleteBoxWithSource(box.ID, "admin"); err != nil {
return false, err
}
return true, nil
}
if err := s.SaveBox(box); err != nil {
return false, err
}
s.logger.Info("admin removed file", "source", "admin", "severity", "user_activity", "code", 2305, "box_id", box.ID, "file_id", fileID)
return false, nil
}
// AdminUpdateBox lets an admin change a box's expiry, download limit, and
// optionally clear password protection.
func (s *UploadService) AdminUpdateBox(boxID string, expiresAt time.Time, maxDownloads int, removePassword bool) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if !expiresAt.IsZero() {
box.ExpiresAt = expiresAt.UTC()
}
if maxDownloads < 0 {
maxDownloads = 0
}
box.MaxDownloads = maxDownloads
if removePassword {
box.PasswordHash = ""
box.PasswordSalt = ""
box.Obfuscate = false
}
if err := s.SaveBox(box); err != nil {
return err
}
s.logger.Info("admin updated box", "source", "admin", "severity", "user_activity", "code", 2306, "box_id", box.ID)
return nil
}
func (s *UploadService) FindFile(box Box, fileID string) (File, error) { func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
for _, file := range box.Files { for _, file := range box.Files {
if file.ID == fileID { if file.ID == fileID {
@@ -726,19 +874,28 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
files := make([]ResultFile, 0, len(box.Files)) files := make([]ResultFile, 0, len(box.Files))
for _, file := range box.Files { for _, file := range box.Files {
files = append(files, ResultFile{ files = append(files, ResultFile{
ID: file.ID, ID: file.ID,
Name: file.Name, Name: file.Name,
Size: helpers.FormatBytes(file.Size), Size: helpers.FormatBytes(file.Size),
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID), URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
}) })
} }
// The box-level thumbnail points at the most recently added file, so a
// per-file ShareX upload previews the file it just sent.
thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID)
if len(files) > 0 {
thumbnailURL = files[len(files)-1].ThumbnailURL
}
result := UploadResult{ result := UploadResult{
BoxID: box.ID, BoxID: box.ID,
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID), BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID), ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
ExpiresAt: box.ExpiresAt.Format(time.RFC3339), ThumbnailURL: thumbnailURL,
Files: files, ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
Files: files,
} }
if deleteToken != "" { if deleteToken != "" {
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken) result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

View File

@@ -58,6 +58,69 @@
--surface-2: rgba(39, 39, 42, 0.28); --surface-2: rgba(39, 39, 42, 0.28);
} }
:root[data-theme="gruvbox"] {
color-scheme: dark;
--background: #1d2021;
--foreground: #ebdbb2;
--card: #282828;
--card-foreground: #ebdbb2;
--muted: #32302f;
--muted-foreground: #bdae93;
--accent: #3c3836;
--accent-foreground: #fbf1c7;
--border: rgba(235, 219, 178, 0.18);
--input: rgba(235, 219, 178, 0.24);
--primary: #d79921;
--primary-foreground: #1d2021;
--primary-hover: #fabd2f;
--ring: #fe8019;
--success: #b8bb26;
--danger: #fb4934;
--radius: 0.65rem;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--header-bg: rgba(29, 32, 33, 0.86);
--body-bg:
radial-gradient(circle at 20% -8%, rgba(215, 153, 33, 0.2), transparent 28rem),
radial-gradient(circle at 85% 8%, rgba(184, 187, 38, 0.12), transparent 26rem),
linear-gradient(180deg, #1d2021 0%, #181a1b 100%);
--surface-1: rgba(235, 219, 178, 0.06);
--surface-1-hover: rgba(235, 219, 178, 0.11);
--surface-2: rgba(251, 241, 199, 0.04);
}
:root[data-theme="cyberpunk"] {
color-scheme: dark;
--background: #08070d;
--foreground: #fff36f;
--card: #16131f;
--card-foreground: #fff36f;
--muted: #251d34;
--muted-foreground: #9bfaff;
--accent: #332246;
--accent-foreground: #fff36f;
--border: rgba(255, 242, 0, 0.24);
--input: rgba(0, 240, 255, 0.34);
--primary: #fff200;
--primary-foreground: #08070d;
--primary-hover: #00f0ff;
--ring: #ff2a6d;
--success: #00ff9f;
--danger: #ff2a6d;
--radius: 0.35rem;
--shadow: 0 24px 70px rgba(255, 42, 109, 0.16), 0 0 34px rgba(0, 240, 255, 0.12);
--font-sans: "Inter", "Rajdhani", "Orbitron", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--header-bg: rgba(8, 7, 13, 0.86);
--body-bg:
radial-gradient(circle at 10% -10%, rgba(255, 242, 0, 0.2), transparent 26rem),
radial-gradient(circle at 90% 8%, rgba(0, 240, 255, 0.18), transparent 26rem),
radial-gradient(circle at 45% 110%, rgba(255, 42, 109, 0.18), transparent 30rem),
linear-gradient(180deg, #08070d 0%, #120b1a 100%);
--surface-1: rgba(0, 240, 255, 0.07);
--surface-1-hover: rgba(255, 242, 0, 0.12);
--surface-2: rgba(255, 42, 109, 0.06);
}
:root[data-theme="retro"] { :root[data-theme="retro"] {
color-scheme: light; color-scheme: light;
--background: #ffffff; --background: #ffffff;
@@ -98,6 +161,7 @@ html {
font-family: var(--font-sans); font-family: var(--font-sans);
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
overflow-x: clip;
} }
body { body {
@@ -107,12 +171,27 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--body-bg); background: var(--body-bg);
overflow-x: clip;
}
@supports not (overflow-x: clip) {
html,
body {
overflow-x: hidden;
}
} }
a { a {
color: inherit; color: inherit;
} }
img,
video,
canvas,
iframe {
max-width: 100%;
}
svg { svg {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
@@ -176,10 +255,18 @@ svg {
} }
.brand { .brand {
min-width: 0;
font-weight: 650; font-weight: 650;
text-decoration: none; text-decoration: none;
} }
.brand > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-mark { .brand-mark {
width: 1.75rem; width: 1.75rem;
height: 1.75rem; height: 1.75rem;
@@ -312,12 +399,15 @@ label span {
input, input,
select, select,
textarea,
button { button {
font: inherit; font: inherit;
max-width: 100%;
} }
input, input,
select { select,
textarea {
width: 100%; width: 100%;
min-height: 2.25rem; min-height: 2.25rem;
border: 1px solid var(--input); border: 1px solid var(--input);
@@ -354,6 +444,8 @@ input:disabled {
.button, .button,
button { button {
min-width: 0;
max-width: 100%;
min-height: 2.25rem; min-height: 2.25rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -372,6 +464,14 @@ button {
cursor: pointer; cursor: pointer;
} }
.button > span,
button > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.button-primary { .button-primary {
background: var(--primary); background: var(--primary);
color: var(--primary-foreground); color: var(--primary-foreground);
@@ -433,6 +533,8 @@ pre code {
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
max-width: 100%;
min-width: 0;
min-height: 1.5rem; min-height: 1.5rem;
border-radius: 999px; border-radius: 999px;
background: var(--muted); background: var(--muted);
@@ -440,6 +542,9 @@ pre code {
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.sr-only { .sr-only {

View File

@@ -1,5 +1,6 @@
.app-shell { .app-shell {
width: min(86rem, calc(100% - 2rem)); width: min(86rem, calc(100% - 2rem));
max-width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 2rem 0; padding: 2rem 0;
display: grid; display: grid;
@@ -8,6 +9,7 @@
} }
.app-sidebar { .app-sidebar {
min-width: 0;
position: sticky; position: sticky;
top: 5rem; top: 5rem;
align-self: start; align-self: start;
@@ -20,6 +22,7 @@
} }
.sidebar-link { .sidebar-link {
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.55rem; gap: 0.55rem;
@@ -30,6 +33,13 @@
text-decoration: none; text-decoration: none;
} }
.sidebar-link span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-link:hover, .sidebar-link:hover,
.sidebar-link.is-active { .sidebar-link.is-active {
border-color: var(--border); border-color: var(--border);
@@ -100,7 +110,7 @@
.inline-controls input, .inline-controls input,
.inline-controls select { .inline-controls select {
min-width: 15rem; min-width: min(15rem, 100%);
} }
.compact-input { .compact-input {
@@ -108,10 +118,18 @@
} }
.settings-form { .settings-form {
min-width: 0;
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
} }
.settings-form > *,
.settings-section > *,
.tabs-bar > *,
.tab-list > * {
min-width: 0;
}
.settings-form-narrow { .settings-form-narrow {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 0.9rem; gap: 0.9rem;
@@ -207,6 +225,7 @@
top: calc(100% + 0.5rem); top: calc(100% + 0.5rem);
z-index: 10; z-index: 10;
width: 15rem; width: 15rem;
max-width: min(15rem, calc(100vw - 2rem));
padding: 1rem; padding: 1rem;
background: color-mix(in srgb, var(--card) 97%, #000); background: color-mix(in srgb, var(--card) 97%, #000);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -226,6 +245,7 @@
/* Copyable URL field */ /* Copyable URL field */
.copy-field { .copy-field {
display: flex; display: flex;
min-width: 0;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
margin-top: 0.75rem; margin-top: 0.75rem;

View File

@@ -2,19 +2,19 @@
* Revamp ("Aurora glass") flourishes. * Revamp ("Aurora glass") flourishes.
* *
* These rules only apply to the default/revamp theme. They are scoped to * These rules only apply to the default/revamp theme. They are scoped to
* :root:not([data-theme="classic"]):not([data-theme="retro"]) so they cover both the explicit * :root exclusions so they cover both the explicit data-theme="revamp"
* data-theme="revamp" attribute AND the no-JS default (no attribute), while * attribute AND the no-JS default (no attribute), while never touching the
* never touching the classic theme. Token colours live in 00-base.css; this * alternate themes. Token colours live in 00-base.css; this file adds the
* file adds the things a flat token swap can't: the animated aurora backdrop, * things a flat token swap can't: the animated aurora backdrop, frosted glass,
* frosted glass, gradient accents, glow and motion. * gradient accents, glow and motion.
*/ */
:root:not([data-theme="classic"]):not([data-theme="retro"]) { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* Animated aurora backdrop ------------------------------------------------ */ /* Animated aurora backdrop ------------------------------------------------ */
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) body::before {
content: ""; content: "";
position: fixed; position: fixed;
inset: -20vmax; inset: -20vmax;
@@ -29,7 +29,7 @@
animation: aurora-drift 26s ease-in-out infinite alternate; animation: aurora-drift 26s ease-in-out infinite alternate;
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::after { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) body::after {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -52,13 +52,13 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) body::before {
animation: none; animation: none;
} }
} }
/* Frosted glass cards ----------------------------------------------------- */ /* Frosted glass cards ----------------------------------------------------- */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .card { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .card {
background: linear-gradient( background: linear-gradient(
155deg, 155deg,
color-mix(in srgb, var(--card) 78%, transparent), color-mix(in srgb, var(--card) 78%, transparent),
@@ -70,20 +70,20 @@
} }
/* Sticky header gets the same glassy treatment */ /* Sticky header gets the same glassy treatment */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .site-header { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .site-header {
backdrop-filter: blur(20px) saturate(150%); backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%);
} }
/* Brand mark glows */ /* Brand mark glows */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .brand-mark { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .brand-mark {
background: linear-gradient(135deg, #8b5cf6, #6366f1 55%, #22d3ee); background: linear-gradient(135deg, #8b5cf6, #6366f1 55%, #22d3ee);
color: #fff; color: #fff;
box-shadow: 0 6px 18px rgba(124, 58, 237, 0.45); box-shadow: 0 6px 18px rgba(124, 58, 237, 0.45);
} }
/* Headings get a soft gradient sheen */ /* Headings get a soft gradient sheen */
:root:not([data-theme="classic"]):not([data-theme="retro"]) h1 { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) h1 {
background: linear-gradient(120deg, #f5f3ff 0%, #c4b5fd 60%, #67e8f9 100%); background: linear-gradient(120deg, #f5f3ff 0%, #c4b5fd 60%, #67e8f9 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
@@ -91,8 +91,8 @@
} }
/* Gradient primary buttons ------------------------------------------------ */ /* Gradient primary buttons ------------------------------------------------ */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary, :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button.is-active { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button.is-active {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%); background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
color: #fff; color: #fff;
border-color: transparent; border-color: transparent;
@@ -100,43 +100,43 @@
transition: transform 140ms ease, box-shadow 160ms ease, filter 160ms ease; transition: transform 140ms ease, box-shadow 160ms ease, filter 160ms ease;
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:hover { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary:hover {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%); background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
filter: brightness(1.08); filter: brightness(1.08);
box-shadow: 0 12px 30px rgba(99, 102, 241, 0.5); box-shadow: 0 12px 30px rgba(99, 102, 241, 0.5);
transform: translateY(-1px); transform: translateY(-1px);
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:active { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary:active {
transform: translateY(0); transform: translateY(0);
} }
/* Outline / ghost buttons get a subtle lift on hover */ /* Outline / ghost buttons get a subtle lift on hover */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline, :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-outline,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-ghost {
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline:hover, :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-outline:hover,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost:hover { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-ghost:hover {
border-color: rgba(168, 150, 255, 0.4); border-color: rgba(168, 150, 255, 0.4);
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Glow focus rings -------------------------------------------------------- */ /* Glow focus rings -------------------------------------------------------- */
:root:not([data-theme="classic"]):not([data-theme="retro"]) :focus-visible { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) :focus-visible {
outline: 2px solid transparent; outline: 2px solid transparent;
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring), 0 0 16px rgba(167, 139, 250, 0.55); box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring), 0 0 16px rgba(167, 139, 250, 0.55);
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) input:focus, :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) input:focus,
:root:not([data-theme="classic"]):not([data-theme="retro"]) select:focus { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) select:focus {
border-color: var(--ring); border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22);
} }
/* Drop zone: animated, glowing -------------------------------------------- */ /* Drop zone: animated, glowing -------------------------------------------- */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone {
border-color: rgba(168, 150, 255, 0.3); border-color: rgba(168, 150, 255, 0.3);
background: background:
radial-gradient(120% 90% at 50% 0%, rgba(139, 92, 246, 0.1), transparent 70%), radial-gradient(120% 90% at 50% 0%, rgba(139, 92, 246, 0.1), transparent 70%),
@@ -144,18 +144,18 @@
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease; transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease;
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone:hover, :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone:hover,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone.is-dragging {
border-color: #a78bfa; border-color: #a78bfa;
box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.4), 0 18px 50px rgba(99, 102, 241, 0.28); box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.4), 0 18px 50px rgba(99, 102, 241, 0.28);
transform: translateY(-2px); transform: translateY(-2px);
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-icon { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-icon {
color: #c4b5fd; color: #c4b5fd;
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging .drop-icon { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone.is-dragging .drop-icon {
animation: drop-bounce 700ms ease infinite; animation: drop-bounce 700ms ease infinite;
} }
@@ -165,34 +165,34 @@
} }
/* Badges pick up a tinted glass look */ /* Badges pick up a tinted glass look */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .badge { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .badge {
background: rgba(139, 92, 246, 0.14); background: rgba(139, 92, 246, 0.14);
color: #d6ccff; color: #d6ccff;
border: 1px solid rgba(168, 150, 255, 0.22); border: 1px solid rgba(168, 150, 255, 0.22);
} }
/* File / result rows lift on hover */ /* File / result rows lift on hover */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item, :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .download-item,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .result-item { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .result-item {
background: color-mix(in srgb, var(--card) 60%, transparent); background: color-mix(in srgb, var(--card) 60%, transparent);
border-color: rgba(168, 150, 255, 0.14); border-color: rgba(168, 150, 255, 0.14);
transition: border-color 140ms ease, transform 140ms ease, background 140ms ease; transition: border-color 140ms ease, transform 140ms ease, background 140ms ease;
} }
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item:hover { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .download-item:hover {
border-color: rgba(168, 150, 255, 0.34); border-color: rgba(168, 150, 255, 0.34);
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Thumbnails on the download page */ /* Thumbnails on the download page */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .file-emblem { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .file-emblem {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(34, 211, 238, 0.18)); background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(34, 211, 238, 0.18));
color: #d6ccff; color: #d6ccff;
border: 1px solid rgba(168, 150, 255, 0.22); border: 1px solid rgba(168, 150, 255, 0.22);
} }
/* Gentle entrance for primary content cards */ /* Gentle entrance for primary content cards */
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) main > * {
animation: rise-in 420ms ease both; animation: rise-in 420ms ease both;
} }
@@ -208,7 +208,7 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * { :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) main > * {
animation: none; animation: none;
} }
} }

View File

@@ -152,16 +152,16 @@
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs /* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
are styled as their own Win98 controls below, so they're excluded here. */ are styled as their own Win98 controls below, so they're excluded here. */
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab) { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link) {
color: #0000ee; color: #0000ee;
text-decoration: underline; text-decoration: underline;
} }
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):visited { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited {
color: #551a8b; color: #551a8b;
} }
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):hover { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):hover {
color: #ee0000; color: #ee0000;
} }

View File

@@ -0,0 +1,88 @@
/*
* Gruvbox theme polish.
*
* Core colour tokens live in 00-base.css. This file adds the warmer, grounded
* Gruvbox-specific surface treatment without changing layout behavior.
*/
:root[data-theme="gruvbox"] .site-header {
border-bottom-color: rgba(250, 189, 47, 0.2);
backdrop-filter: blur(16px) saturate(130%);
-webkit-backdrop-filter: blur(16px) saturate(130%);
}
:root[data-theme="gruvbox"] .brand-mark {
background: linear-gradient(135deg, #d79921, #fe8019);
color: #1d2021;
box-shadow: 0 8px 22px rgba(254, 128, 25, 0.22);
}
:root[data-theme="gruvbox"] .card,
:root[data-theme="gruvbox"] .app-sidebar,
:root[data-theme="gruvbox"] .storage-card,
:root[data-theme="gruvbox"] .storage-op-card,
:root[data-theme="gruvbox"] .metric-card,
:root[data-theme="gruvbox"] .logs-filter-card {
background: color-mix(in srgb, var(--card) 92%, #1d2021);
border-color: rgba(235, 219, 178, 0.16);
}
:root[data-theme="gruvbox"] .admin-shell .app-sidebar {
border-color: rgba(250, 189, 47, 0.32);
background: linear-gradient(180deg, rgba(215, 153, 33, 0.12), rgba(40, 40, 40, 0.94));
}
:root[data-theme="gruvbox"] .admin-shell .sidebar-link.is-active {
border-color: rgba(250, 189, 47, 0.36);
background: rgba(215, 153, 33, 0.14);
}
:root[data-theme="gruvbox"] .admin-shell .kicker,
:root[data-theme="gruvbox"] .kicker {
color: #fabd2f;
}
:root[data-theme="gruvbox"] h1 {
color: #fbf1c7;
}
:root[data-theme="gruvbox"] .button-primary,
:root[data-theme="gruvbox"] .button.is-active {
border-color: rgba(250, 189, 47, 0.3);
background: linear-gradient(135deg, #d79921, #fabd2f);
color: #1d2021;
box-shadow: 0 10px 24px rgba(215, 153, 33, 0.2);
}
:root[data-theme="gruvbox"] .button-primary:hover {
background: linear-gradient(135deg, #fabd2f, #fe8019);
}
:root[data-theme="gruvbox"] .button-outline,
:root[data-theme="gruvbox"] .button-ghost:hover,
:root[data-theme="gruvbox"] .button-outline:hover {
border-color: rgba(235, 219, 178, 0.2);
}
:root[data-theme="gruvbox"] .badge-active,
:root[data-theme="gruvbox"] .storage-detail-test.is-ok > span:last-child {
color: #b8bb26;
}
:root[data-theme="gruvbox"] .badge-disabled,
:root[data-theme="gruvbox"] .storage-detail-test.is-err > span:last-child,
:root[data-theme="gruvbox"] .form-error {
color: #fb4934;
}
:root[data-theme="gruvbox"] input:focus,
:root[data-theme="gruvbox"] select:focus,
:root[data-theme="gruvbox"] textarea:focus {
border-color: #fe8019;
box-shadow: 0 0 0 3px rgba(254, 128, 25, 0.18);
}
:root[data-theme="gruvbox"] ::selection {
background: #d79921;
color: #1d2021;
}

View File

@@ -0,0 +1,196 @@
/*
* CyberPunk theme polish.
*
* Inspired by neon Cyberpunk 2077 UI treatments: warning yellow surfaces,
* cyan/magenta light, hard edges, scanlines, and high-contrast panels.
*/
:root[data-theme="cyberpunk"] body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
background-size: 100% 3px, 3rem 100%;
mix-blend-mode: screen;
}
:root[data-theme="cyberpunk"] body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
linear-gradient(115deg, transparent 0 18%, rgba(255, 242, 0, 0.06) 18% 19%, transparent 19% 100%),
linear-gradient(245deg, transparent 0 76%, rgba(255, 42, 109, 0.08) 76% 77%, transparent 77% 100%);
}
:root[data-theme="cyberpunk"] .site-header {
border-bottom-color: rgba(255, 242, 0, 0.32);
box-shadow: 0 0 22px rgba(0, 240, 255, 0.12);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
}
:root[data-theme="cyberpunk"] .brand {
text-transform: lowercase;
letter-spacing: 0.02em;
}
:root[data-theme="cyberpunk"] .brand-mark {
background: #fff200;
color: #08070d;
box-shadow: 0 0 0 1px rgba(0, 240, 255, 0.45), 0 0 18px rgba(255, 242, 0, 0.42);
clip-path: polygon(0 0, 100% 0, 100% 72%, 78% 100%, 0 100%);
}
:root[data-theme="cyberpunk"] h1 {
color: #fff200;
text-shadow: 2px 0 0 rgba(255, 42, 109, 0.58), -2px 0 0 rgba(0, 240, 255, 0.46);
}
:root[data-theme="cyberpunk"] .card,
:root[data-theme="cyberpunk"] .app-sidebar,
:root[data-theme="cyberpunk"] .storage-card,
:root[data-theme="cyberpunk"] .storage-op-card,
:root[data-theme="cyberpunk"] .metric-card,
:root[data-theme="cyberpunk"] .logs-filter-card,
:root[data-theme="cyberpunk"] .advanced-options {
position: relative;
background:
linear-gradient(145deg, rgba(22, 19, 31, 0.96), rgba(13, 10, 20, 0.96)),
linear-gradient(90deg, rgba(255, 242, 0, 0.16), rgba(0, 240, 255, 0.08));
border-color: rgba(255, 242, 0, 0.28);
box-shadow: var(--shadow);
}
:root[data-theme="cyberpunk"] .card::before,
:root[data-theme="cyberpunk"] .storage-card::before,
:root[data-theme="cyberpunk"] .metric-card::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-top: 1px solid rgba(0, 240, 255, 0.4);
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
:root[data-theme="cyberpunk"] .admin-shell .app-sidebar {
border-color: rgba(255, 42, 109, 0.38);
background:
linear-gradient(180deg, rgba(255, 42, 109, 0.16), rgba(8, 7, 13, 0.94)),
#16131f;
}
:root[data-theme="cyberpunk"] .sidebar-link:hover,
:root[data-theme="cyberpunk"] .sidebar-link.is-active,
:root[data-theme="cyberpunk"] .admin-shell .sidebar-link.is-active {
border-color: rgba(255, 242, 0, 0.42);
background: linear-gradient(90deg, rgba(255, 242, 0, 0.2), rgba(0, 240, 255, 0.08));
color: #fff200;
}
:root[data-theme="cyberpunk"] .kicker,
:root[data-theme="cyberpunk"] .admin-shell .kicker {
color: #00f0ff;
text-shadow: 0 0 12px rgba(0, 240, 255, 0.36);
}
:root[data-theme="cyberpunk"] .button-primary,
:root[data-theme="cyberpunk"] .button.is-active {
border-color: #fff200;
background: #fff200;
color: #08070d;
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.7), 0 0 18px rgba(255, 242, 0, 0.3);
clip-path: polygon(0 0, calc(100% - 0.7rem) 0, 100% 0.7rem, 100% 100%, 0.7rem 100%, 0 calc(100% - 0.7rem));
}
:root[data-theme="cyberpunk"] .button-primary:hover,
:root[data-theme="cyberpunk"] .button.is-active:hover {
background: #00f0ff;
border-color: #00f0ff;
color: #08070d;
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.78), 0 0 22px rgba(0, 240, 255, 0.42);
}
:root[data-theme="cyberpunk"] .button-outline,
:root[data-theme="cyberpunk"] .button-ghost {
border-color: rgba(0, 240, 255, 0.28);
}
:root[data-theme="cyberpunk"] .button-outline:hover,
:root[data-theme="cyberpunk"] .button-ghost:hover {
border-color: rgba(255, 242, 0, 0.46);
background: rgba(255, 242, 0, 0.1);
}
:root[data-theme="cyberpunk"] input,
:root[data-theme="cyberpunk"] select,
:root[data-theme="cyberpunk"] textarea {
background: rgba(8, 7, 13, 0.92);
border-color: rgba(0, 240, 255, 0.34);
}
:root[data-theme="cyberpunk"] input:focus,
:root[data-theme="cyberpunk"] select:focus,
:root[data-theme="cyberpunk"] textarea:focus {
border-color: #fff200;
box-shadow: 0 0 0 3px rgba(255, 242, 0, 0.16), 0 0 22px rgba(0, 240, 255, 0.18);
}
:root[data-theme="cyberpunk"] .badge {
border: 1px solid rgba(0, 240, 255, 0.22);
background: rgba(0, 240, 255, 0.08);
color: #9bfaff;
}
:root[data-theme="cyberpunk"] .badge-active,
:root[data-theme="cyberpunk"] .storage-detail-test.is-ok > span:last-child {
color: #00ff9f;
}
:root[data-theme="cyberpunk"] .badge-disabled,
:root[data-theme="cyberpunk"] .storage-detail-test.is-err > span:last-child,
:root[data-theme="cyberpunk"] .form-error {
color: #ff2a6d;
}
:root[data-theme="cyberpunk"] .drop-zone {
border-color: rgba(255, 242, 0, 0.34);
background:
linear-gradient(145deg, rgba(255, 242, 0, 0.08), transparent 38%),
rgba(8, 7, 13, 0.76);
}
:root[data-theme="cyberpunk"] .drop-zone:hover,
:root[data-theme="cyberpunk"] .drop-zone.is-dragging {
border-color: #00f0ff;
background:
linear-gradient(145deg, rgba(0, 240, 255, 0.14), transparent 42%),
rgba(8, 7, 13, 0.82);
}
:root[data-theme="cyberpunk"] ::selection {
background: #ff2a6d;
color: #ffffff;
}
@media (prefers-reduced-motion: no-preference) {
:root[data-theme="cyberpunk"] .brand-mark,
:root[data-theme="cyberpunk"] h1 {
animation: cyberpunk-pulse 4s ease-in-out infinite;
}
}
@keyframes cyberpunk-pulse {
0%, 100% {
filter: none;
}
50% {
filter: drop-shadow(0 0 0.45rem rgba(0, 240, 255, 0.28));
}
}

View File

@@ -36,6 +36,22 @@
font-size: 1rem; font-size: 1rem;
} }
.docs-card h3 {
margin: 1.35rem 0 0;
font-size: 0.9rem;
font-weight: 650;
color: var(--foreground);
}
/* Highlights where the API token goes in the ShareX config snippet. */
.sxcu-highlight {
background: #fde047;
color: #1a1a1a;
font-weight: 700;
padding: 0 0.2rem;
border-radius: 3px;
}
.docs-card p { .docs-card p {
margin: 0.65rem 0 0; margin: 0.65rem 0 0;
color: var(--muted-foreground); color: var(--muted-foreground);

View File

@@ -1,11 +1,19 @@
.admin-header, .admin-header,
.table-header { .table-header {
display: flex; display: flex;
min-width: 0;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
} }
.admin-header > *,
.table-header > *,
.admin-grid-two > *,
.logs-filter-card > * {
min-width: 0;
}
.kicker { .kicker {
margin: 0 0 0.4rem; margin: 0 0 0.4rem;
color: var(--muted-foreground); color: var(--muted-foreground);
@@ -54,7 +62,8 @@
white-space: nowrap; white-space: nowrap;
} }
.user-edit-metrics { .user-edit-metrics,
.metric-grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@@ -72,12 +81,15 @@
} }
.admin-table-wrap { .admin-table-wrap {
max-width: 100%;
overflow-x: auto; overflow-x: auto;
margin-top: 1rem; margin-top: 1rem;
-webkit-overflow-scrolling: touch;
} }
.admin-table { .admin-table {
width: 100%; width: 100%;
min-width: 46rem;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -95,6 +107,185 @@
font-weight: 650; font-weight: 650;
} }
.sort-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--muted-foreground);
font-weight: 650;
text-decoration: none;
}
.sort-link:hover,
.sort-link.is-sorted {
color: var(--foreground);
}
.sort-arrow {
font-size: 0.7rem;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
}
.pagination-summary {
margin: 0.6rem 0 0;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.pagination-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1rem;
}
.pagination-bar .pagination {
margin-top: 0;
}
.per-page-control {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.per-page-control select {
width: auto;
min-width: 4.5rem;
min-height: 2rem;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.button.is-disabled {
pointer-events: none;
opacity: 0.45;
}
/* Overview charts */
.admin-charts {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
.chart-card {
min-width: 0;
}
.chart-card h2 {
margin: 0;
font-size: 1.05rem;
}
.chart-card .muted-copy {
margin: 0.3rem 0 0;
}
.bar-chart {
display: flex;
align-items: stretch;
gap: 0.4rem;
height: 180px;
margin-top: 1.25rem;
padding-top: 0.5rem;
}
.bar-chart-col {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
flex: 1;
min-width: 0;
align-items: center;
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;
}
.bar-chart-bar {
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));
}
.bar-chart-value {
color: var(--foreground);
font-size: 0.72rem;
font-weight: 650;
}
.bar-chart-label {
color: var(--muted-foreground);
font-size: 0.66rem;
white-space: nowrap;
}
.stat-bars {
display: grid;
gap: 0.9rem;
margin-top: 1.25rem;
}
.stat-bar span {
display: flex;
justify-content: space-between;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.stat-bar span strong {
color: var(--foreground);
}
.stat-bar-track {
margin-top: 0.35rem;
height: 0.55rem;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.stat-bar-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--primary, #8b5cf6);
}
@media (max-width: 900px) {
.admin-charts {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.metric-grid-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.table-actions { .table-actions {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -204,6 +395,7 @@
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
align-items: center; align-items: center;
flex-wrap: wrap;
margin-top: 0.4rem; margin-top: 0.4rem;
} }

View File

@@ -23,6 +23,7 @@
.storage-card-header { .storage-card-header {
display: flex; display: flex;
min-width: 0;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
@@ -56,6 +57,10 @@
.storage-card-name { .storage-card-name {
display: block; display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 650; font-weight: 650;
color: var(--foreground); color: var(--foreground);
@@ -82,9 +87,15 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.storage-card-actions form {
min-width: 0;
margin: 0;
}
/* View-mode summary */ /* View-mode summary */
.storage-card-summary { .storage-card-summary {
display: flex; display: flex;
min-width: 0;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0 1.75rem; gap: 0 1.75rem;
padding: 0.65rem 1.1rem 0.9rem; padding: 0.65rem 1.1rem 0.9rem;
@@ -96,6 +107,7 @@
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.15rem;
min-width: 8rem; min-width: 8rem;
max-width: 100%;
} }
.storage-detail > span:first-child, .storage-detail > span:first-child,
@@ -137,6 +149,14 @@
align-items: end; align-items: end;
} }
.storage-card-fields > *,
.storage-ops-grid > *,
.storage-result-row,
.storage-result-row summary > *,
.storage-result-detail > * {
min-width: 0;
}
.storage-card-fields label { .storage-card-fields label {
display: grid; display: grid;
gap: 0.28rem; gap: 0.28rem;

View File

@@ -1,12 +1,34 @@
@media (max-width: 720px) { @media (max-width: 720px) {
.nav-links { .nav {
display: inline-flex; width: min(72rem, calc(100% - 1rem));
min-height: auto;
padding: 0.55rem 0;
align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; gap: 0.55rem;
}
.brand {
flex: 1 1 auto;
}
.nav-links {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: stretch;
gap: 0.4rem;
}
.nav-links .button {
flex: 1 1 auto;
min-width: 0;
padding-inline: 0.55rem;
} }
.upload-view, .upload-view,
.download-view { .download-view {
width: min(100%, calc(100% - 1rem));
min-height: auto; min-height: auto;
padding: 2rem 0; padding: 2rem 0;
} }
@@ -37,6 +59,23 @@
.app-sidebar { .app-sidebar {
position: static; position: static;
width: 100%;
overflow: hidden;
}
.sidebar-nav {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
}
.sidebar-link {
justify-content: flex-start;
padding-inline: 0.65rem;
}
.sidebar-logout .button {
justify-content: center;
} }
.endpoint-list div { .endpoint-list div {
@@ -86,9 +125,59 @@
.new-collection-body { .new-collection-body {
position: static; position: static;
width: 100%; width: 100%;
max-width: 100%;
margin-top: 0.5rem; margin-top: 0.5rem;
box-shadow: none; box-shadow: none;
} }
.inline-controls {
align-items: stretch;
}
.inline-controls label,
.inline-controls input,
.inline-controls select,
.compact-input {
width: 100%;
min-width: 0;
}
.copy-field,
.token-reveal-row,
.storage-card-edit-bar {
flex-wrap: wrap;
}
.copy-field .button,
.token-reveal-row .button,
.storage-card-edit-bar .button {
flex: 1 1 auto;
}
.storage-card-header,
.storage-card-actions {
align-items: stretch;
}
.storage-card-header {
flex-direction: column;
}
.storage-card-actions,
.storage-card-actions form,
.storage-card-actions .button,
.storage-card-actions button {
width: 100%;
}
.storage-card-summary {
gap: 0.65rem;
}
.storage-detail {
min-width: 0;
width: 100%;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -96,3 +185,61 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 520px) {
.app-shell {
width: min(100%, calc(100% - 1rem));
padding: 1rem 0;
gap: 1rem;
}
.card-content {
padding: 1rem;
}
.metric-grid,
.user-edit-metrics {
grid-template-columns: 1fr;
}
.storage-type-grid,
.storage-ops-grid {
grid-template-columns: 1fr;
}
.result-item,
.download-item {
align-items: stretch;
flex-wrap: wrap;
}
.file-actions,
.file-browser.is-thumbs .file-actions {
width: 100%;
display: grid;
grid-template-columns: 1fr;
}
.file-progress-side {
width: 100%;
}
.site-footer {
width: min(100%, calc(100% - 1rem));
}
}
@media (max-width: 380px) {
.sidebar-nav {
grid-template-columns: 1fr;
}
.badge-row .badge {
flex: 1 1 100%;
justify-content: center;
}
.nav-links .button {
flex-basis: 100%;
}
}

View File

@@ -11,7 +11,7 @@
*/ */
(function () { (function () {
var STORAGE_KEY = "warpbox-theme"; var STORAGE_KEY = "warpbox-theme";
var THEMES = ["revamp", "classic", "retro"]; var THEMES = ["revamp", "classic", "retro", "gruvbox", "cyberpunk"];
function stored() { function stored() {
try { try {

View File

@@ -1,4 +1,16 @@
(function () { (function () {
document.querySelectorAll("[data-storage-delete-warning]").forEach((button) => {
button.addEventListener("click", (event) => {
const name = button.getAttribute("data-storage-delete-warning") || "this storage backend";
const confirmed = window.confirm(
`Delete ${name}?\n\nAll boxes stored on this location will also be deleted. Any global defaults or user storage overrides pointing at it will be reset back to inherited local storage.`
);
if (!confirmed) {
event.preventDefault();
}
});
});
document.querySelectorAll("[data-storage-speed-open]").forEach((button) => { document.querySelectorAll("[data-storage-speed-open]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const modal = document.querySelector("[data-storage-speed-modal]"); const modal = document.querySelector("[data-storage-speed-modal]");

View File

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

View File

@@ -15,23 +15,26 @@
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}} {{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
{{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}} {{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}}
<script src="/static/js/05-theme.js"></script> <script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
<link rel="stylesheet" href="/static/css/00-base.css"> <link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/10-layout.css"> <link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/15-revamp.css"> <link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/16-retro.css"> <link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/20-upload.css"> <link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/30-download.css"> <link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/40-docs.css"> <link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/50-admin.css"> <link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/60-storage.css"> <link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/70-tokens.css"> <link rel="stylesheet" href="/static/css/50-admin.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/90-responsive.css"> <link rel="stylesheet" href="/static/css/60-storage.css?version={{.AppVersion}}">
<script defer src="/static/js/00-utils.js"></script> <link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
<script defer src="/static/js/10-file-browser.js"></script> <link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
<script defer src="/static/js/20-storage-admin.js"></script> <script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/30-token-copy.js"></script> <script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/40-upload.js"></script> <script defer src="/static/js/20-storage-admin.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>
</head> </head>
<body class="dark"> <body class="dark">
<a class="skip-link" href="#main">Skip to content</a> <a class="skip-link" href="#main">Skip to content</a>
@@ -60,13 +63,15 @@
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}} · self-hosted</span> <span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}}</span>
<label class="theme-picker"> <label class="theme-picker">
<span>Theme</span> <span>Theme</span>
<select data-theme-select aria-label="Site theme"> <select data-theme-select aria-label="Site theme">
<option value="revamp">Aurora (default)</option> <option value="revamp">Aurora (default)</option>
<option value="classic">Classic</option> <option value="classic">Classic</option>
<option value="retro">Web 1.0 (retro)</option> <option value="retro">Web 1.0 (retro)</option>
<option value="gruvbox">Gruvbox</option>
<option value="cyberpunk">CyberPunk</option>
</select> </select>
</label> </label>
<span class="footer-links">{{if .CurrentUser}}<a href="/app">Dashboard</a><a href="/api">API</a><a href="/account/settings">Account</a>{{else}}<a href="/login">Sign in</a><a href="/api">API</a>{{end}}</span> <span class="footer-links">{{if .CurrentUser}}<a href="/app">Dashboard</a><a href="/api">API</a><a href="/account/settings">Account</a>{{else}}<a href="/login">Sign in</a><a href="/api">API</a>{{end}}</span>

View File

@@ -58,6 +58,55 @@
</article> </article>
</div> </div>
<div class="admin-charts">
<div class="card chart-card">
<div class="card-content">
<h2>Uploads per day</h2>
<p class="muted-copy">New boxes created over the last 14 days.</p>
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
{{range .Data.Overview.UploadDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
<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-label">{{.Label}}</span>
</div>
{{end}}
</div>
</div>
</div>
<div class="card chart-card">
<div class="card-content">
<h2>Box status</h2>
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
<div class="stat-bars">
{{range .Data.Overview.StatusBars}}
<div class="stat-bar">
<span>{{.Label}} <strong>{{.Value}}</strong></span>
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
</div>
{{end}}
</div>
</div>
</div>
</div>
<div class="card chart-card">
<div class="card-content">
<h2>Storage added per day</h2>
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
{{range .Data.Overview.StorageDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
<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-label">{{.Label}}</span>
</div>
{{end}}
</div>
</div>
</div>
<div class="card admin-table-card"> <div class="card admin-table-card">
<div class="card-content"> <div class="card-content">
<div class="table-header"> <div class="table-header">

View File

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

View File

@@ -0,0 +1,131 @@
{{define "admin_box_edit.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-box-edit-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link is-active" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console · <a href="/admin/files">Files</a></p>
<h1 id="admin-box-edit-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Box <code>{{.Data.Box.ID}}</code> · {{.Data.Box.Owner}}</p>
</div>
<a class="button button-outline" href="/admin/boxes/{{.Data.Box.ID}}/view">Open box</a>
</div>
{{if .Data.Notice}}<p class="form-success">{{.Data.Notice}}</p>{{end}}
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Box settings</h2>
<p>Change expiration, download limit, and protection.</p>
</div>
</div>
<dl class="manage-details">
<div><dt>Created</dt><dd>{{.Data.Box.CreatedAt}}</dd></div>
<div><dt>Files</dt><dd>{{.Data.Box.FileCount}}</dd></div>
<div><dt>Total size</dt><dd>{{.Data.Box.TotalSize}}</dd></div>
<div><dt>Downloads</dt><dd>{{.Data.Box.DownloadCount}}{{if .Data.Box.MaxDownloads}} / {{.Data.Box.MaxDownloads}}{{end}}</dd></div>
<div><dt>Expires</dt><dd>{{.Data.Box.ExpiresLabel}}</dd></div>
<div><dt>Storage backend</dt><dd>{{.Data.Box.BackendID}}</dd></div>
<div><dt>Protected</dt><dd>{{if .Data.Box.Protected}}Yes{{else}}No{{end}}</dd></div>
</dl>
<form class="settings-form settings-form-narrow" action="/admin/boxes/{{.Data.Box.ID}}/edit" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
<span>Expires at (UTC)</span>
<input type="datetime-local" name="expires_at" value="{{.Data.Box.ExpiresInput}}">
</label>
<label class="checkbox-field">
<input type="checkbox" name="never_expires" {{if .Data.Box.NeverExpires}}checked{{end}}>
<span>Never expires (overrides the date above)</span>
</label>
<label>
<span>Max downloads (0 = unlimited)</span>
<input type="number" min="0" name="max_downloads" value="{{.Data.Box.MaxDownloads}}">
</label>
{{if .Data.Box.Protected}}
<label class="checkbox-field">
<input type="checkbox" name="remove_password">
<span>Remove password protection</span>
</label>
{{end}}
<button class="button button-primary" type="submit">Save changes</button>
</form>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Files</h2>
<p>Remove individual files from this box. Removing the last file deletes the box.</p>
</div>
</div>
<div class="result-list">
{{range .Data.Files}}
<article class="download-item">
{{if .HasPreview}}<a class="thumb-link" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer"><img src="{{.ThumbnailURL}}" alt="" loading="lazy"></a>{{end}}
<a class="file-main" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer">
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
<small>{{.Size}} · {{.ContentType}}</small>
</a>
<div class="file-actions">
<a class="button button-outline button-sm" href="{{.DownloadURL}}" download="{{.Name}}">Download</a>
<form action="/admin/boxes/{{$.Data.Box.ID}}/files/{{.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit">Remove</button>
</form>
</div>
</article>
{{else}}
<p class="muted-copy">This box has no files.</p>
{{end}}
</div>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Danger zone</h2>
<p>Permanently delete this box and all of its files.</p>
</div>
<form action="/admin/boxes/{{.Data.Box.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-danger" type="submit">Delete box</button>
</form>
</div>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,114 @@
{{define "admin_files.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-files-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link is-active" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-files-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">{{.Data.Total}} box{{if ne .Data.Total 1}}es{{end}} total.</p>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>All uploads</h2>
<p>Search, sort, and manage every box.</p>
</div>
<form class="inline-controls" method="get" action="/admin/files">
<input type="hidden" name="sort" value="{{.Data.Sort}}">
<input type="hidden" name="dir" value="{{.Data.Dir}}">
<input type="hidden" name="per" value="{{.Data.PerPage}}">
<label>
<span class="sr-only">Search</span>
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
</label>
<button class="button button-primary button-sm" type="submit">Search</button>
{{if .Data.Query}}<a class="button button-outline button-sm" href="/admin/files">Clear</a>{{end}}
</form>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
{{range .Data.Columns}}
<th><a class="sort-link {{if .Sorted}}is-sorted{{end}}" href="{{.Href}}">{{.Label}}{{if .Sorted}}<span class="sort-arrow" aria-hidden="true">{{if .Ascending}}▲{{else}}▼{{end}}</span>{{end}}</a></th>
{{end}}
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Boxes}}
<tr>
<td><a href="/admin/boxes/{{.ID}}/edit"><code>{{.ID}}</code></a></td>
<td>{{.Owner}}</td>
<td>{{.FileCount}}</td>
<td>{{.TotalSizeLabel}}</td>
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.ExpiresAt}}</td>
<td>
{{if .Expired}}<span class="badge">expired</span>{{else}}<span class="badge">active</span>{{end}}
{{if .Protected}}<span class="badge">protected</span>{{end}}
</td>
<td class="table-actions">
<a class="button button-primary button-sm" href="/admin/boxes/{{.ID}}/edit">Edit</a>
<a class="button button-outline button-sm" href="/admin/boxes/{{.ID}}/view">View</a>
<form action="/admin/boxes/{{.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="9">No boxes match.</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="pagination-bar">
<nav class="pagination" aria-label="Pagination">
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
</nav>
<label class="per-page-control">
<span>Per page</span>
<select data-per-page="files" aria-label="Items per page">
{{range .Data.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.PerPage}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
</div>
<p class="pagination-summary">Showing {{.Data.RangeFrom}}{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
</div>
</div>
</div>
</section>
{{end}}

View File

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

View File

@@ -76,6 +76,7 @@
{{if eq .Config.ID "local"}}<span class="badge">Required</span> {{if eq .Config.ID "local"}}<span class="badge">Required</span>
{{else if .Config.Enabled}}<span class="badge badge-active">Enabled</span> {{else if .Config.Enabled}}<span class="badge badge-active">Enabled</span>
{{else}}<span class="badge badge-disabled">Disabled</span>{{end}} {{else}}<span class="badge badge-disabled">Disabled</span>{{end}}
{{if .InUseReason}}<span class="badge" title="{{.InUseReason}}">In use</span>{{end}}
{{if .UsageLabel}}<span class="storage-card-usage">{{.UsageLabel}}</span>{{end}} {{if .UsageLabel}}<span class="storage-card-usage">{{.UsageLabel}}</span>{{end}}
</div> </div>
</div> </div>
@@ -92,15 +93,9 @@
{{end}} {{end}}
{{if ne .Config.ID "local"}} {{if ne .Config.ID "local"}}
<a class="button button-outline button-sm" href="/admin/storage/{{.Config.ID}}/edit">Edit</a> <a class="button button-outline button-sm" href="/admin/storage/{{.Config.ID}}/edit">Edit</a>
{{if .Config.Enabled}}
<form action="/admin/storage/{{.Config.ID}}/disable" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-outline button-sm" type="submit" {{if .InUse}}disabled title="Backend is in use"{{end}}>Disable</button>
</form>
{{end}}
<form action="/admin/storage/{{.Config.ID}}/delete" method="post"> <form action="/admin/storage/{{.Config.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit" {{if .InUse}}disabled title="Backend is in use"{{end}}>Delete</button> <button class="button button-danger button-sm" type="submit" data-storage-delete-warning="{{.Config.Name}}">Delete</button>
</form> </form>
{{end}} {{end}}
</div> </div>

View File

@@ -52,7 +52,7 @@
<div class="table-header"> <div class="table-header">
<div> <div>
<h2>Identity and limits</h2> <h2>Identity and limits</h2>
<p>Blank limit fields inherit the global user defaults. Use -1 for unlimited upload size or daily upload caps. Storage quota set to 0 means unlimited.</p> <p>Blank limit fields inherit the global user defaults. Use <code>-1</code> for unlimited in any limit field — upload size, daily caps, storage quota, max expiration (the box can then last forever), daily boxes, active boxes, and short-window requests. Storage quota <code>0</code> also means unlimited.</p>
</div> </div>
</div> </div>
<form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post"> <form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post">
@@ -89,10 +89,10 @@
<h3 class="settings-section-title">Upload limits</h3> <h3 class="settings-section-title">Upload limits</h3>
<label><span>Max upload size (MB)</span><input name="max_upload_mb" value="{{.Data.UserEdit.MaxUploadMB}}" placeholder="inherit"></label> <label><span>Max upload size (MB)</span><input name="max_upload_mb" value="{{.Data.UserEdit.MaxUploadMB}}" placeholder="inherit"></label>
<label><span>Daily upload cap (MB)</span><input name="daily_upload_mb" value="{{.Data.UserEdit.DailyUploadMB}}" placeholder="inherit"></label> <label><span>Daily upload cap (MB)</span><input name="daily_upload_mb" value="{{.Data.UserEdit.DailyUploadMB}}" placeholder="inherit"></label>
<label><span>Max expiration (days)</span><input type="number" min="1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label> <label><span>Max expiration (days)</span><input type="number" min="-1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label>
<label><span>Daily boxes</span><input type="number" min="1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label> <label><span>Daily boxes</span><input type="number" min="-1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label>
<label><span>Active boxes</span><input type="number" min="1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label> <label><span>Active boxes</span><input type="number" min="-1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label>
<label><span>Short-window requests</span><input type="number" min="1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label> <label><span>Short-window requests</span><input type="number" min="-1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label>
</div> </div>
<button class="button button-primary" type="submit">Save user</button> <button class="button button-primary" type="submit">Save user</button>

View File

@@ -36,11 +36,12 @@
<article class="card docs-card"> <article class="card docs-card">
<div class="card-content"> <div class="card-content">
<h2>JSON response</h2> <h2>JSON response</h2>
<p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private.</p> <p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private. On error the body is <code>{ "error": "message" }</code> with a non-2xx status (e.g. rate limited or over a limit).</p>
<pre><code>{ <pre><code>{
"boxId": "abc123", "boxId": "abc123",
"boxUrl": "{{.Data.BaseURL}}/d/abc123", "boxUrl": "{{.Data.BaseURL}}/d/abc123",
"zipUrl": "{{.Data.BaseURL}}/d/abc123/zip", "zipUrl": "{{.Data.BaseURL}}/d/abc123/zip",
"thumbnailUrl": "{{.Data.BaseURL}}/d/abc123/thumb/file123",
"manageUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token", "manageUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token",
"deleteUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token/delete", "deleteUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token/delete",
"expiresAt": "2026-06-05T12:00:00Z", "expiresAt": "2026-06-05T12:00:00Z",
@@ -49,7 +50,8 @@
"id": "file123", "id": "file123",
"name": "report.pdf", "name": "report.pdf",
"size": "2.4 MiB", "size": "2.4 MiB",
"url": "{{.Data.BaseURL}}/d/abc123/f/file123" "url": "{{.Data.BaseURL}}/d/abc123/f/file123",
"thumbnailUrl": "{{.Data.BaseURL}}/d/abc123/thumb/file123"
} }
] ]
}</code></pre> }</code></pre>
@@ -59,22 +61,44 @@
<article class="card docs-card"> <article class="card docs-card">
<div class="card-content"> <div class="card-content">
<h2>ShareX setup</h2> <h2>ShareX setup</h2>
<p>Import the uploader, then add your API key to upload as your account — with your account's size, daily, and retention limits — instead of as an anonymous guest.</p>
<h3>1 · Import the uploader</h3>
<ol class="docs-steps"> <ol class="docs-steps">
<li>Download the instance config: <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>/api/v1/sharex/warpbox-anonymous.sxcu</code></a>.</li> <li>Download <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>warpbox-anonymous.sxcu</code></a>.</li>
<li>Or open the tracked template at <code>{{.Data.ShareXExamplePath}}</code> and change <code>RequestURL</code> to <code>{{.Data.ShareXExampleURL}}</code>.</li> <li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
<li>Keep <code>FileFormName</code> as <code>{{.Data.ShareXFileFieldName}}</code>.</li>
<li>Import the <code>.sxcu</code> file into ShareX as a custom uploader.</li>
<li>Upload a file. ShareX will use <code>boxUrl</code> as the public URL and <code>manageUrl</code> as the deletion URL.</li>
</ol> </ol>
<h3>2 · Add your API key (upload as your account)</h3>
<ol class="docs-steps">
<li>Create a personal access token under <a href="/account/settings">Account → Access tokens</a> and copy it.</li>
<li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li>
<li>Add a header — Name <code>Authorization</code>, Value <code>Bearer &lt;your token&gt;</code>.</li>
</ol>
<p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p>
<pre><code>{ <pre><code>{
"Version": "1.0.0",
"Name": "Warpbox (my account)",
"DestinationType": "ImageUploader, FileUploader, TextUploader",
"RequestMethod": "POST", "RequestMethod": "POST",
"RequestURL": "{{.Data.ShareXExampleURL}}", "RequestURL": "{{.Data.ShareXExampleURL}}",
"Headers": { "Accept": "application/json" }, "Headers": {
"Accept": "application/json",
"Authorization": "Bearer <span class="sxcu-highlight">YOUR_API_TOKEN</span>",
"X-Warpbox-Batch": "sharex"
},
"Body": "MultipartFormData", "Body": "MultipartFormData",
"FileFormName": "{{.Data.ShareXFileFieldName}}", "FileFormName": "{{.Data.ShareXFileFieldName}}",
"URL": "$json:boxUrl$", "URL": "{json:boxUrl}",
"DeletionURL": "$json:manageUrl$" "ThumbnailURL": "{json:thumbnailUrl}",
"DeletionURL": "{json:deleteUrl}",
"ErrorMessage": "{json:error}"
}</code></pre> }</code></pre>
<h3>Grouping multiple files into one box</h3>
<p>Grouping is <strong>opt-in via the <code>X-Warpbox-Batch</code> request header</strong> — without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a multi-file ShareX selection produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p>
<p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p>
</div> </div>
</article> </article>

View File

@@ -5,10 +5,13 @@
"RequestMethod": "POST", "RequestMethod": "POST",
"RequestURL": "https://warpbox.dev/api/v1/upload", "RequestURL": "https://warpbox.dev/api/v1/upload",
"Headers": { "Headers": {
"Accept": "application/json" "Accept": "application/json",
"X-Warpbox-Batch": "sharex"
}, },
"Body": "MultipartFormData", "Body": "MultipartFormData",
"FileFormName": "sharex", "FileFormName": "sharex",
"URL": "$json:boxUrl$", "URL": "{json:boxUrl}",
"DeletionURL": "$json:manageUrl$" "ThumbnailURL": "{json:thumbnailUrl}",
"DeletionURL": "{json:deleteUrl}",
"ErrorMessage": "{json:error}"
} }