Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38afc6c34d | |||
| 9a5be44a7f | |||
| 48722f0aab | |||
| 94cf9531fa | |||
| 60d2ea0204 | |||
| ffa2d9636b |
@@ -695,6 +695,57 @@ 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 TestAdminOverviewRendersBarHeightVariables(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
uploadThroughApp(t, app)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminDashboard(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "--bar-height: 100%") {
|
||||
t.Fatalf("admin overview did not render a full-height bar: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `style="height:`) {
|
||||
t.Fatalf("admin overview still uses fragile percent height styles: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
@@ -987,6 +1038,7 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
lines := strings.Join([]string{
|
||||
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
|
||||
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
|
||||
`{"date":"2026-05-31","time":"12:36:56","source":"http","severity":"dev","code":200,"log":"http request","method":"GET","path":"/health","ip":"127.0.0.1","user_agent":"Wget"}`,
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
|
||||
@@ -1007,6 +1059,16 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
if strings.Contains(logsBody, "172.30.0.1:48358") {
|
||||
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
|
||||
}
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
|
||||
healthRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
healthResponse := httptest.NewRecorder()
|
||||
app.AdminLogs(healthResponse, healthRequest)
|
||||
if healthResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminLogs health status = %d, body = %s", healthResponse.Code, healthResponse.Body.String())
|
||||
}
|
||||
if strings.Contains(healthResponse.Body.String(), "/health") || strings.Contains(healthResponse.Body.String(), "Wget") {
|
||||
t.Fatalf("AdminLogs rendered container health ping: %s", healthResponse.Body.String())
|
||||
}
|
||||
|
||||
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
|
||||
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
@@ -36,6 +37,7 @@ type adminPageData struct {
|
||||
StorageTypes []adminStorageProviderView
|
||||
Logs adminLogsView
|
||||
Bans adminBansView
|
||||
Overview adminOverview
|
||||
Section string
|
||||
PageTitle string
|
||||
LastInviteURL string
|
||||
@@ -44,16 +46,32 @@ type adminPageData struct {
|
||||
}
|
||||
|
||||
type adminLogsView struct {
|
||||
Entries []adminLogEntry
|
||||
Dates []string
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
TotalShown int
|
||||
Entries []adminLogEntry
|
||||
Dates []string
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
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 {
|
||||
Date string
|
||||
Time string
|
||||
@@ -134,6 +152,24 @@ type adminStorageProviderView struct {
|
||||
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 {
|
||||
ID string
|
||||
Owner string
|
||||
@@ -248,25 +284,142 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
boxes, err := a.adminBoxes(8)
|
||||
allBoxes, err := a.uploadService.AdminBoxes(0)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
|
||||
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
overview := buildAdminOverview(allBoxes, stats)
|
||||
recent := a.recentBoxViews(allBoxes, 8)
|
||||
|
||||
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
|
||||
Title: "Admin overview",
|
||||
Description: "Warpbox admin overview.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Boxes: boxes,
|
||||
Boxes: recent,
|
||||
Overview: overview,
|
||||
Section: "overview",
|
||||
PageTitle: "Admin overview",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// recentBoxViews renders the newest boxes (already sorted newest-first by the
|
||||
// service) into display rows, resolving owner labels.
|
||||
func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxView {
|
||||
if limit > 0 && len(boxes) > limit {
|
||||
boxes = boxes[:limit]
|
||||
}
|
||||
cache := map[string]string{}
|
||||
rows := make([]adminBoxView, 0, len(boxes))
|
||||
for _, box := range boxes {
|
||||
rows = append(rows, adminBoxView{
|
||||
ID: box.ID,
|
||||
Owner: a.boxOwnerLabel(box.OwnerID, cache),
|
||||
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04"),
|
||||
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04"),
|
||||
FileCount: box.FileCount,
|
||||
TotalSizeLabel: box.TotalSizeLabel,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
Protected: box.Protected,
|
||||
Expired: box.Expired,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildAdminOverview computes the last-14-day upload/storage series plus a few
|
||||
// status distributions for the overview dashboard.
|
||||
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
|
||||
const days = 14
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
counts := make([]int, days)
|
||||
bytes := make([]int64, days)
|
||||
labels := make([]string, days)
|
||||
for i := 0; i < days; i++ {
|
||||
day := today.AddDate(0, 0, -(days - 1 - i))
|
||||
labels[i] = day.Format("Jan 2")
|
||||
}
|
||||
|
||||
for _, box := range boxes {
|
||||
created := box.CreatedAt.UTC()
|
||||
day := time.Date(created.Year(), created.Month(), created.Day(), 0, 0, 0, 0, time.UTC)
|
||||
offset := int(today.Sub(day).Hours() / 24)
|
||||
idx := days - 1 - offset
|
||||
if idx < 0 || idx >= days {
|
||||
continue
|
||||
}
|
||||
counts[idx]++
|
||||
bytes[idx] += box.TotalSize
|
||||
}
|
||||
|
||||
maxCount := 0
|
||||
var maxBytes int64
|
||||
for i := 0; i < days; i++ {
|
||||
if counts[i] > maxCount {
|
||||
maxCount = counts[i]
|
||||
}
|
||||
if bytes[i] > maxBytes {
|
||||
maxBytes = bytes[i]
|
||||
}
|
||||
}
|
||||
|
||||
uploadDays := make([]adminChartBar, days)
|
||||
storageDays := make([]adminChartBar, days)
|
||||
for i := 0; i < days; i++ {
|
||||
uploadDays[i] = adminChartBar{
|
||||
Label: labels[i],
|
||||
Value: strconv.Itoa(counts[i]),
|
||||
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) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
@@ -1174,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: boxExpiryLabel(box.ExpiresAt, "Jan 2 15:04"),
|
||||
FileCount: box.FileCount,
|
||||
TotalSizeLabel: box.TotalSizeLabel,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
Protected: box.Protected,
|
||||
Expired: box.Expired,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.isAdmin(r) {
|
||||
return true
|
||||
@@ -1465,21 +1586,111 @@ func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) {
|
||||
}
|
||||
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{
|
||||
Entries: entries,
|
||||
Dates: dates,
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
TotalShown: len(entries),
|
||||
Entries: entries[start:end],
|
||||
Dates: dates,
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
@@ -1520,11 +1731,29 @@ func readLogEntries(file string) ([]adminLogEntry, error) {
|
||||
if err := json.Unmarshal(line, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if isHealthCheckLogEntry(raw) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, logEntryFromMap(raw))
|
||||
}
|
||||
return entries, scanner.Err()
|
||||
}
|
||||
|
||||
func isHealthCheckLogEntry(raw map[string]any) bool {
|
||||
path := strings.TrimSpace(firstLogString(raw, "path", "route"))
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
fields := strings.Fields(path)
|
||||
if len(fields) > 0 {
|
||||
path = fields[len(fields)-1]
|
||||
}
|
||||
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
return path == "/health" || path == "/healthz" || path == "/api/v1/health"
|
||||
}
|
||||
|
||||
func logEntryFromMap(raw map[string]any) adminLogEntry {
|
||||
entry := adminLogEntry{
|
||||
Date: logString(raw, "date"),
|
||||
|
||||
@@ -14,27 +14,40 @@ import (
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
const adminFilesPageSize = 25
|
||||
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
|
||||
TotalPages int
|
||||
Total int
|
||||
RangeFrom int
|
||||
RangeTo int
|
||||
Columns []adminFilesColumn
|
||||
PageLinks []adminFilesPageLink
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevHref string
|
||||
NextHref string
|
||||
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 {
|
||||
@@ -153,8 +166,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 + adminFilesPageSize - 1) / adminFilesPageSize
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
@@ -165,11 +181,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
start := (page - 1) * adminFilesPageSize
|
||||
start := (page - 1) * perPage
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + adminFilesPageSize
|
||||
end := start + perPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
@@ -200,24 +216,26 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
TotalPages: totalPages,
|
||||
Total: total,
|
||||
RangeFrom: rangeFrom,
|
||||
RangeTo: end,
|
||||
Columns: adminFilesColumns(query, sortKey, dir),
|
||||
PageLinks: adminFilesPageLinks(query, sortKey, dir, page, totalPages),
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevHref: adminFilesHref(query, sortKey, dir, page-1),
|
||||
NextHref: adminFilesHref(query, sortKey, dir, page+1),
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -274,7 +292,7 @@ func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) {
|
||||
})
|
||||
}
|
||||
|
||||
func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
||||
func adminFilesColumns(state adminFilesQuery, sortKey, dir string) []adminFilesColumn {
|
||||
defs := []struct{ Key, Label string }{
|
||||
{"id", "Box"},
|
||||
{"owner", "Owner"},
|
||||
@@ -291,9 +309,12 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
||||
if sorted && dir == "asc" {
|
||||
nextDir = "desc"
|
||||
}
|
||||
colState := state
|
||||
colState.Sort = def.Key
|
||||
colState.Dir = nextDir
|
||||
columns = append(columns, adminFilesColumn{
|
||||
Label: def.Label,
|
||||
Href: adminFilesHref(query, def.Key, nextDir, 1),
|
||||
Href: adminFilesHref(colState, 1),
|
||||
Sorted: sorted,
|
||||
Ascending: dir == "asc",
|
||||
})
|
||||
@@ -301,7 +322,7 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
||||
return columns
|
||||
}
|
||||
|
||||
func adminFilesPageLinks(query, sortKey, dir string, page, totalPages int) []adminFilesPageLink {
|
||||
func adminFilesPageLinks(state adminFilesQuery, page, totalPages int) []adminFilesPageLink {
|
||||
links := make([]adminFilesPageLink, 0, 5)
|
||||
const window = 2
|
||||
for p := page - window; p <= page+window; p++ {
|
||||
@@ -310,23 +331,26 @@ func adminFilesPageLinks(query, sortKey, dir string, page, totalPages int) []adm
|
||||
}
|
||||
links = append(links, adminFilesPageLink{
|
||||
Page: p,
|
||||
Href: adminFilesHref(query, sortKey, dir, p),
|
||||
Href: adminFilesHref(state, p),
|
||||
Active: p == page,
|
||||
})
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func adminFilesHref(query, sortKey, dir string, page int) string {
|
||||
func adminFilesHref(state adminFilesQuery, page int) string {
|
||||
values := url.Values{}
|
||||
if query != "" {
|
||||
values.Set("q", query)
|
||||
if state.Query != "" {
|
||||
values.Set("q", state.Query)
|
||||
}
|
||||
if sortKey != "" && sortKey != "created" {
|
||||
values.Set("sort", sortKey)
|
||||
if state.Sort != "" && state.Sort != "created" {
|
||||
values.Set("sort", state.Sort)
|
||||
}
|
||||
if dir != "" && dir != "desc" {
|
||||
values.Set("dir", dir)
|
||||
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))
|
||||
@@ -337,6 +361,21 @@ func adminFilesHref(query, sortKey, dir string, page int) string {
|
||||
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
|
||||
|
||||
@@ -21,6 +21,18 @@ type apiDocsData struct {
|
||||
}
|
||||
|
||||
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
||||
user, loggedIn := a.currentUser(r)
|
||||
actor := "anonymous"
|
||||
if loggedIn {
|
||||
actor = "user"
|
||||
}
|
||||
a.logger.Info("api docs viewed", withRequestLogAttrs(r,
|
||||
"source", "page",
|
||||
"severity", "user_activity",
|
||||
"code", 2501,
|
||||
"actor", actor,
|
||||
"user_id", user.ID,
|
||||
)...)
|
||||
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
|
||||
Title: "API documentation",
|
||||
Description: "Curl and ShareX upload examples for Warpbox.",
|
||||
|
||||
@@ -35,7 +35,7 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||
a.logger.Warn("registration rate limited", "source", "auth", "severity", "warn", "code", 4291, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("registration rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4291)...)
|
||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
||||
return
|
||||
}
|
||||
@@ -45,11 +45,11 @@ func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("bootstrap registration failed", "source", "auth", "severity", "warn", "code", 4400, "ip", uploadClientIP(r), "email", r.FormValue("email"), "error", err.Error())
|
||||
a.logger.Warn("bootstrap registration failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4400, "email", r.FormValue("email"), "error", err.Error())...)
|
||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("first admin created", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)...)
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
@@ -58,12 +58,13 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("login page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2503, "actor", "anonymous")...)
|
||||
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||
a.logger.Warn("login rate limited", "source", "auth", "severity", "warn", "code", 4292, "ip", uploadClientIP(r), "email", r.FormValue("email"))
|
||||
a.logger.Warn("login rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4292, "email", r.FormValue("email"))...)
|
||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
||||
return
|
||||
}
|
||||
@@ -77,13 +78,13 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"), "ip", uploadClientIP(r))
|
||||
a.logger.Warn("login failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))...)
|
||||
a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed")
|
||||
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
||||
return
|
||||
}
|
||||
a.setUserSessionCookie(w, r, token)
|
||||
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("user login", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)...)
|
||||
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if user, ok := a.currentUser(r); ok {
|
||||
a.logger.Info("user logout", "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("user logout", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID)...)
|
||||
}
|
||||
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
||||
_ = a.authService.Logout(cookie.Value)
|
||||
@@ -107,6 +108,7 @@ func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
|
||||
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||
return
|
||||
}
|
||||
a.logger.Info("invite page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2504, "invite_email", invite.Email, "reset", invite.UserID != "")...)
|
||||
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
invite, err := a.authService.InviteByToken(token)
|
||||
if err != nil {
|
||||
a.logger.Warn("invite accept invalid", "source", "auth", "severity", "warn", "code", 4404, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("invite accept invalid", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4404)...)
|
||||
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||
return
|
||||
}
|
||||
@@ -124,11 +126,11 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("invite accept failed", "source", "auth", "severity", "warn", "code", 4405, "ip", uploadClientIP(r), "invite_email", invite.Email, "error", err.Error())
|
||||
a.logger.Warn("invite accept failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4405, "invite_email", invite.Email, "error", err.Error())...)
|
||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "ip", uploadClientIP(r), "invite_email", invite.Email)
|
||||
a.logger.Info("invite accepted", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "invite_email", invite.Email)...)
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
@@ -153,6 +155,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.logger.Info("account settings viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2505, "user_id", user.ID)...)
|
||||
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
||||
}
|
||||
|
||||
@@ -170,11 +173,11 @@ func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
||||
if err != nil {
|
||||
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
|
||||
a.logger.Warn("api token create failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())...)
|
||||
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
||||
return
|
||||
}
|
||||
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
|
||||
a.logger.Info("api token created", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)...)
|
||||
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
||||
}
|
||||
|
||||
@@ -184,9 +187,9 @@ func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
||||
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
||||
a.logger.Warn("api token delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())...)
|
||||
} else {
|
||||
a.logger.Info("api token deleted", "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))
|
||||
a.logger.Info("api token deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))...)
|
||||
}
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
@@ -233,16 +236,16 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
||||
a.logger.Warn("password change failed current password", "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("password change failed current password", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID)...)
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
||||
a.logger.Warn("password change failed", "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())
|
||||
a.logger.Warn("password change failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())...)
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("password changed", "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("password changed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID)...)
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.logger.Info("user dashboard viewed", withRequestLogAttrs(r,
|
||||
"source", "page",
|
||||
"severity", "user_activity",
|
||||
"code", 2502,
|
||||
"user_id", user.ID,
|
||||
)...)
|
||||
collections, err := a.authService.ListCollections(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load collections", http.StatusInternalServerError)
|
||||
@@ -112,9 +118,9 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
||||
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
|
||||
a.logger.Warn("collection create failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())...)
|
||||
} else {
|
||||
a.logger.Info("collection created", "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))
|
||||
a.logger.Info("collection created", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))...)
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
@@ -129,11 +135,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
||||
a.logger.Warn("owned box rename failed", "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.logger.Warn("owned box rename failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box renamed", "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
||||
a.logger.Info("owned box renamed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -148,16 +154,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
collectionID := r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
a.logger.Warn("owned box move invalid collection", "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
||||
a.logger.Warn("owned box move invalid collection", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
|
||||
http.Error(w, "collection not found", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
||||
a.logger.Warn("owned box move failed", "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.logger.Warn("owned box move failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box moved", "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
||||
a.logger.Info("owned box moved", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -167,11 +173,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||
a.logger.Warn("owned box delete failed", "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.logger.Warn("owned box delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box deleted", "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
||||
a.logger.Info("owned box deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,12 @@ type previewPageData struct {
|
||||
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("download page missing box", "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
a.logger.Warn("download page missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"))...)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("download page unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
a.logger.Warn("download page unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
|
||||
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
|
||||
Title: "Download unavailable",
|
||||
Description: "This Warpbox link is no longer available.",
|
||||
@@ -101,7 +101,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
ExpiresLabel: expiresLabel,
|
||||
},
|
||||
})
|
||||
a.logger.Info("download page viewed", "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "ip", uploadClientIP(r), "locked", locked)
|
||||
a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
@@ -139,7 +139,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
DownloadURL: view.DownloadURL,
|
||||
},
|
||||
})
|
||||
a.logger.Info("file preview page viewed", "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("file preview page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID)...)
|
||||
}
|
||||
|
||||
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -148,13 +148,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
a.logger.Warn("protected file download blocked", "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("protected file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID)...)
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
||||
a.logger.Info("file content served", "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "attachment", r.URL.Query().Get("inline") != "1")
|
||||
a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...)
|
||||
}
|
||||
|
||||
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -202,7 +202,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
|
||||
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("box unlock failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)...)
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -215,26 +215,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
Secure: r.TLS != nil,
|
||||
Expires: box.ExpiresAt,
|
||||
})
|
||||
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("box unlocked", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)...)
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("file request missing box", "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
||||
a.logger.Warn("file request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"))...)
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("file request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r), "error", err.Error())
|
||||
a.logger.Warn("file request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "error", err.Error())...)
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
|
||||
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("file request missing file", "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
||||
a.logger.Warn("file request missing file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"))...)
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
|
||||
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
|
||||
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
a.logger.Warn("file object missing", "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
a.logger.Warn("file object missing", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -280,17 +280,17 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
||||
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("zip request missing box", "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("zip request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
a.logger.Warn("zip request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
a.logger.Warn("protected zip download blocked", "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("protected zip download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID)...)
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
|
||||
}
|
||||
a.logger.Info("zip downloaded", "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "ip", uploadClientIP(r), "files", len(box.Files))
|
||||
a.logger.Info("zip downloaded", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "files", len(box.Files))...)
|
||||
}
|
||||
|
||||
func (a *App) fileView(box services.Box, file services.File) fileView {
|
||||
|
||||
29
backend/libs/handlers/logging.go
Normal file
29
backend/libs/handlers/logging.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/middleware"
|
||||
)
|
||||
|
||||
func requestLogAttrs(r *http.Request) []any {
|
||||
attrs := []any{
|
||||
"ip", uploadClientIP(r),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
}
|
||||
if requestID := middleware.RequestIDFromContext(r.Context()); requestID != "" {
|
||||
attrs = append(attrs, "request_id", requestID)
|
||||
}
|
||||
if userAgent := r.UserAgent(); userAgent != "" {
|
||||
attrs = append(attrs, "user_agent", userAgent)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func withRequestLogAttrs(r *http.Request, attrs ...any) []any {
|
||||
out := make([]any, 0, len(attrs)+8)
|
||||
out = append(out, attrs...)
|
||||
out = append(out, requestLogAttrs(r)...)
|
||||
return out
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
|
||||
Description: "Delete this anonymous Warpbox upload.",
|
||||
Data: a.managePageData(box, r.PathValue("token")),
|
||||
})
|
||||
a.logger.Info("anonymous manage page viewed", "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("anonymous manage page viewed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID)...)
|
||||
}
|
||||
|
||||
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -41,11 +41,11 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
|
||||
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
a.logger.Warn("anonymous delete failed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error())...)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.logger.Info("anonymous box deleted", "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Info("anonymous box deleted", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID)...)
|
||||
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -60,12 +60,12 @@ func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("anonymous manage missing box", "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
a.logger.Warn("anonymous manage missing box", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"))...)
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, false
|
||||
}
|
||||
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
|
||||
a.logger.Warn("anonymous manage invalid token", "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("anonymous manage invalid token", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID)...)
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, false
|
||||
}
|
||||
|
||||
@@ -46,6 +46,17 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
actor := "anonymous"
|
||||
if loggedIn {
|
||||
actor = "user"
|
||||
}
|
||||
a.logger.Info("upload page viewed", withRequestLogAttrs(r,
|
||||
"source", "page",
|
||||
"severity", "user_activity",
|
||||
"code", 2500,
|
||||
"actor", actor,
|
||||
"user_id", user.ID,
|
||||
)...)
|
||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
||||
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
||||
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||
if authErr != nil {
|
||||
a.logger.Warn("upload rejected invalid bearer token", "source", "user-upload", "severity", "warn", "code", 4010, "ip", uploadClientIP(r), "user_agent", r.UserAgent())
|
||||
a.logger.Warn("upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4010)...)
|
||||
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
@@ -30,14 +30,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
||||
a.logger.Warn("anonymous upload rejected disabled", "source", "user-upload", "severity", "warn", "code", 4012, "ip", uploadClientIP(r))
|
||||
a.logger.Warn("anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4012)...)
|
||||
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
||||
return
|
||||
}
|
||||
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
|
||||
rateKey := uploadRateKey(r, user, loggedIn)
|
||||
if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
||||
a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID)
|
||||
a.logger.Warn("upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4290, "user_id", user.ID)...)
|
||||
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
|
||||
return
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
parseLimit = 32 << 20
|
||||
}
|
||||
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||
a.logger.Warn("upload form parse failed", "source", "user-upload", "severity", "warn", "code", 4000, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
||||
a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...)
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||
return
|
||||
}
|
||||
@@ -65,14 +65,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID = user.ID
|
||||
collectionID = r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
a.logger.Warn("upload rejected invalid collection", "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)
|
||||
a.logger.Warn("upload rejected invalid collection", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)...)
|
||||
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if !isAdminUpload {
|
||||
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
|
||||
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))
|
||||
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))...)
|
||||
helpers.WriteJSONError(w, status, message)
|
||||
return
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
|
||||
a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)
|
||||
a.logger.Warn("upload rejected expiration days", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4131, "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)...)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
@@ -99,13 +99,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
// Only honour it for unlimited uploaders; otherwise it's an invalid value.
|
||||
if expiresMinutes < 0 || rawMaxDays < 0 {
|
||||
if !unlimitedExpiry {
|
||||
a.logger.Warn("upload rejected unlimited expiration", "source", "user-upload", "severity", "warn", "code", 4133, "ip", uploadClientIP(r), "user_id", user.ID)
|
||||
a.logger.Warn("upload rejected unlimited expiration", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4133, "user_id", user.ID)...)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
expiresMinutes = -1
|
||||
} else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 {
|
||||
a.logger.Warn("upload rejected expiration minutes", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)
|
||||
a.logger.Warn("upload rejected expiration minutes", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4132, "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)...)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
@@ -123,12 +123,12 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
result, boxesAdded, status, policyMessage, err := a.createOrAppendBox(r, user, loggedIn, effectivePolicy, files, opts, !isAdminUpload)
|
||||
if policyMessage != "" {
|
||||
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))
|
||||
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))...)
|
||||
helpers.WriteJSONError(w, status, policyMessage)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
||||
a.logger.Warn("upload failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4001, "user_id", user.ID, "error", err.Error())...)
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
||||
a.logger.Info("upload response sent", "source", "user-upload", "severity", "user_activity", "code", 2001, "ip", uploadClientIP(r), "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload)
|
||||
a.logger.Info("box uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2001, "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
|
||||
|
||||
if wantsJSON(r) {
|
||||
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||
|
||||
@@ -50,7 +50,6 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
middleware.SecurityHeaders,
|
||||
middleware.Gzip,
|
||||
middleware.ClientIP(cfg.TrustedProxies),
|
||||
middleware.Logger(logger),
|
||||
middleware.Bans(logger, banService, cfg.TrustedProxies),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(status int) {
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(data []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(data)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func Logger(logger *slog.Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
recorder := &statusRecorder{ResponseWriter: w}
|
||||
|
||||
next.ServeHTTP(recorder, r)
|
||||
|
||||
status := recorder.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
ip, ok := services.ClientIPFromContext(r)
|
||||
if !ok {
|
||||
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
|
||||
}
|
||||
|
||||
logger.Info("http request",
|
||||
"source", "http",
|
||||
"severity", "dev",
|
||||
"code", status,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", status,
|
||||
"bytes", recorder.bytes,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
"ip", ip,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"user_agent", r.UserAgent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-edit-metrics {
|
||||
.user-edit-metrics,
|
||||
.metric-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -138,6 +139,171 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-bar .pagination {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.per-page-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.per-page-control select {
|
||||
width: auto;
|
||||
min-width: 4.5rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.button.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Overview charts */
|
||||
.admin-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chart-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.chart-card .muted-copy {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(14, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
min-height: 15rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-chart-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.bar-chart-track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 1.8rem;
|
||||
min-height: 9rem;
|
||||
margin: 0 auto;
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
|
||||
border-radius: 0.45rem 0.45rem 0 0;
|
||||
background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--border) 55%, transparent));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-chart-bar {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: var(--bar-height, 0%);
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
|
||||
}
|
||||
|
||||
.bar-chart-value {
|
||||
min-height: 1rem;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar-chart-label {
|
||||
overflow: hidden;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.66rem;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-bars {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-bar span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.stat-bar span strong {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.stat-bar-track {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
43
backend/static/js/35-pagination.js
Normal file
43
backend/static/js/35-pagination.js
Normal 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());
|
||||
});
|
||||
})();
|
||||
@@ -33,6 +33,7 @@
|
||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/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>
|
||||
<body class="dark">
|
||||
|
||||
@@ -58,6 +58,55 @@
|
||||
</article>
|
||||
</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="--bar-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="--bar-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-content">
|
||||
<div class="table-header">
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{{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}}
|
||||
|
||||
<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>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
|
||||
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<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">
|
||||
@@ -92,13 +93,19 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if gt .Data.TotalPages 1}}
|
||||
<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>{{end}}
|
||||
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
|
||||
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
||||
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{end}}
|
||||
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
<label class="per-page-control">
|
||||
<span>Per page</span>
|
||||
<select data-per-page="files" aria-label="Items per page">
|
||||
{{range .Data.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.PerPage}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="pagination-summary">Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="per" value="{{.Data.Logs.PerPage}}">
|
||||
<button class="button button-primary" type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
@@ -62,7 +63,7 @@
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<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 class="admin-table-wrap">
|
||||
@@ -98,6 +99,21 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user