Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9f865cd85 | |||
| 38afc6c34d | |||
| 9a5be44a7f | |||
| 48722f0aab | |||
| 94cf9531fa | |||
| 60d2ea0204 |
@@ -695,6 +695,108 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
|
||||
overview := buildAdminOverview([]services.AdminBox{{
|
||||
ID: "box1",
|
||||
CreatedAt: today,
|
||||
TotalSize: 1024,
|
||||
}}, services.AdminStats{TotalBoxes: 1, TotalFiles: 1, TotalSize: 1024})
|
||||
|
||||
for i, bar := range overview.UploadDays {
|
||||
want := 0
|
||||
if i == len(overview.UploadDays)-1 {
|
||||
want = 150
|
||||
}
|
||||
if bar.HeightPx != want {
|
||||
t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want)
|
||||
}
|
||||
}
|
||||
for i, bar := range overview.StorageDays {
|
||||
want := 0
|
||||
if i == len(overview.StorageDays)-1 {
|
||||
want = 150
|
||||
}
|
||||
if bar.HeightPx != want {
|
||||
t.Fatalf("storage bar %d height = %d, want %d", i, bar.HeightPx, want)
|
||||
}
|
||||
}
|
||||
if overview.StatusBars[0].WidthPercent != 100 {
|
||||
t.Fatalf("active status width = %d, want 100", overview.StatusBars[0].WidthPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOverviewChartsScaleRelativeToVisibleRange(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
twoDaysAgo := today.AddDate(0, 0, -2)
|
||||
boxes := []services.AdminBox{
|
||||
{ID: "today-1", CreatedAt: today, TotalSize: 30},
|
||||
{ID: "today-2", CreatedAt: today, TotalSize: 30},
|
||||
{ID: "today-3", CreatedAt: today, TotalSize: 30},
|
||||
{ID: "yesterday-1", CreatedAt: yesterday, TotalSize: 20},
|
||||
{ID: "yesterday-2", CreatedAt: yesterday, TotalSize: 20},
|
||||
{ID: "two-days-ago", CreatedAt: twoDaysAgo, TotalSize: 10},
|
||||
}
|
||||
overview := buildAdminOverview(boxes, services.AdminStats{TotalBoxes: 6, ExpiredBoxes: 2, ProtectedBoxes: 1})
|
||||
|
||||
last := len(overview.UploadDays) - 1
|
||||
if overview.UploadDays[last].HeightPx != 150 {
|
||||
t.Fatalf("3-upload day height = %d, want 150", overview.UploadDays[last].HeightPx)
|
||||
}
|
||||
if overview.UploadDays[last-1].HeightPx != 100 {
|
||||
t.Fatalf("2-upload day height = %d, want 100", overview.UploadDays[last-1].HeightPx)
|
||||
}
|
||||
if overview.UploadDays[last-2].HeightPx != 50 {
|
||||
t.Fatalf("1-upload day height = %d, want 50", overview.UploadDays[last-2].HeightPx)
|
||||
}
|
||||
if overview.StorageDays[last].HeightPx != 150 || overview.StorageDays[last-1].HeightPx != 66 || overview.StorageDays[last-2].HeightPx != 16 {
|
||||
t.Fatalf("storage heights = %d/%d/%d, want 150/66/16", overview.StorageDays[last].HeightPx, overview.StorageDays[last-1].HeightPx, overview.StorageDays[last-2].HeightPx)
|
||||
}
|
||||
if overview.StatusBars[0].WidthPercent != 100 || overview.StatusBars[1].WidthPercent != 50 || overview.StatusBars[2].WidthPercent != 25 {
|
||||
t.Fatalf("status widths = %d/%d/%d, want 100/50/25", overview.StatusBars[0].WidthPercent, overview.StatusBars[1].WidthPercent, overview.StatusBars[2].WidthPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
uploadThroughApp(t, app)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminDashboard(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, `style="height: 150px"`) {
|
||||
t.Fatalf("admin overview did not render a full-height pixel bar: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) {
|
||||
t.Fatalf("admin overview did not render chart fallback data attributes: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `style="height: 0px"`) {
|
||||
t.Fatalf("admin overview did not render zero pixel bars: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `style="width: 100%"`) {
|
||||
t.Fatalf("admin overview did not render a full-width status bar: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `data-width-percent="100"`) || !strings.Contains(body, `data-stat-value=`) {
|
||||
t.Fatalf("admin overview did not render status fallback data attributes: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "--bar-height") {
|
||||
t.Fatalf("admin overview still uses css variable bar heights: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "/static/js/25-admin-charts.js?version=test") {
|
||||
t.Fatalf("admin overview did not load chart fallback script: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
@@ -987,6 +1089,7 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
lines := strings.Join([]string{
|
||||
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
|
||||
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
|
||||
`{"date":"2026-05-31","time":"12:36:56","source":"http","severity":"dev","code":200,"log":"http request","method":"GET","path":"/health","ip":"127.0.0.1","user_agent":"Wget"}`,
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
|
||||
@@ -1007,6 +1110,16 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
if strings.Contains(logsBody, "172.30.0.1:48358") {
|
||||
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
|
||||
}
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
|
||||
healthRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
healthResponse := httptest.NewRecorder()
|
||||
app.AdminLogs(healthResponse, healthRequest)
|
||||
if healthResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminLogs health status = %d, body = %s", healthResponse.Code, healthResponse.Body.String())
|
||||
}
|
||||
if strings.Contains(healthResponse.Body.String(), "/health") || strings.Contains(healthResponse.Body.String(), "Wget") {
|
||||
t.Fatalf("AdminLogs rendered container health ping: %s", healthResponse.Body.String())
|
||||
}
|
||||
|
||||
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
|
||||
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
|
||||
@@ -161,13 +161,15 @@ type adminOverview struct {
|
||||
type adminChartBar struct {
|
||||
Label string
|
||||
Value string
|
||||
Height int // 0-100, percent of the tallest bar
|
||||
HeightPx int
|
||||
RawValue int64
|
||||
}
|
||||
|
||||
type adminStatBar struct {
|
||||
Label string
|
||||
Value string
|
||||
Percent int
|
||||
RawValue int
|
||||
WidthPercent int
|
||||
}
|
||||
|
||||
type adminBoxView struct {
|
||||
@@ -336,6 +338,7 @@ func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxVie
|
||||
// status distributions for the overview dashboard.
|
||||
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
|
||||
const days = 14
|
||||
const chartMaxHeightPx = 150
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -376,12 +379,14 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
|
||||
uploadDays[i] = adminChartBar{
|
||||
Label: labels[i],
|
||||
Value: strconv.Itoa(counts[i]),
|
||||
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
|
||||
HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx),
|
||||
RawValue: int64(counts[i]),
|
||||
}
|
||||
storageDays[i] = adminChartBar{
|
||||
Label: labels[i],
|
||||
Value: helpers.FormatBytes(bytes[i]),
|
||||
Height: scaleHeight(bytes[i], maxBytes),
|
||||
HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx),
|
||||
RawValue: bytes[i],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,10 +394,11 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
|
||||
if activeBoxes < 0 {
|
||||
activeBoxes = 0
|
||||
}
|
||||
maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes)
|
||||
statusBars := []adminStatBar{
|
||||
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)},
|
||||
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)},
|
||||
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)},
|
||||
{Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)},
|
||||
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)},
|
||||
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)},
|
||||
}
|
||||
|
||||
return adminOverview{
|
||||
@@ -402,13 +408,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
|
||||
}
|
||||
}
|
||||
|
||||
func scaleHeight(value, max int64) int {
|
||||
func scaleHeightPx(value, max int64, maxHeightPx int) int {
|
||||
if max <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
height := int(value * 100 / max)
|
||||
if height < 4 {
|
||||
height = 4
|
||||
height := int(value * int64(maxHeightPx) / max)
|
||||
if height < 8 {
|
||||
height = 8
|
||||
}
|
||||
if height > maxHeightPx {
|
||||
return maxHeightPx
|
||||
}
|
||||
return height
|
||||
}
|
||||
@@ -420,6 +429,16 @@ func percentOf(value, total int) int {
|
||||
return value * 100 / total
|
||||
}
|
||||
|
||||
func maxInt(values ...int) int {
|
||||
max := 0
|
||||
for _, value := range values {
|
||||
if value > max {
|
||||
max = value
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
@@ -1731,11 +1750,29 @@ func readLogEntries(file string) ([]adminLogEntry, error) {
|
||||
if err := json.Unmarshal(line, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if isHealthCheckLogEntry(raw) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, logEntryFromMap(raw))
|
||||
}
|
||||
return entries, scanner.Err()
|
||||
}
|
||||
|
||||
func isHealthCheckLogEntry(raw map[string]any) bool {
|
||||
path := strings.TrimSpace(firstLogString(raw, "path", "route"))
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
fields := strings.Fields(path)
|
||||
if len(fields) > 0 {
|
||||
path = fields[len(fields)-1]
|
||||
}
|
||||
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
return path == "/health" || path == "/healthz" || path == "/api/v1/health"
|
||||
}
|
||||
|
||||
func logEntryFromMap(raw map[string]any) adminLogEntry {
|
||||
entry := adminLogEntry{
|
||||
Date: logString(raw, "date"),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -196,42 +196,65 @@
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(14, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: 0.4rem;
|
||||
height: 180px;
|
||||
min-height: 13rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-chart-col {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-chart-track {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 1.8rem;
|
||||
height: 150px;
|
||||
margin: 0 auto;
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
|
||||
border-radius: 0.45rem 0.45rem 0 0;
|
||||
background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--border) 55%, transparent));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-chart-bar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 2.2rem;
|
||||
min-height: 2px;
|
||||
min-height: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: linear-gradient(180deg, var(--primary, #8b5cf6), color-mix(in srgb, var(--primary, #8b5cf6) 55%, transparent));
|
||||
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
|
||||
}
|
||||
|
||||
.bar-chart-value {
|
||||
min-height: 1rem;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar-chart-label {
|
||||
overflow: hidden;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.66rem;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -253,6 +276,8 @@
|
||||
}
|
||||
|
||||
.stat-bar-track {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
@@ -263,6 +288,7 @@
|
||||
.stat-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--primary, #8b5cf6);
|
||||
}
|
||||
|
||||
57
backend/static/js/25-admin-charts.js
Normal file
57
backend/static/js/25-admin-charts.js
Normal file
@@ -0,0 +1,57 @@
|
||||
(function () {
|
||||
const maxBarHeight = 150;
|
||||
|
||||
function numberAttr(element, name) {
|
||||
const value = Number(element.getAttribute(name));
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function applyChartBars() {
|
||||
document.querySelectorAll(".bar-chart").forEach((chart) => {
|
||||
const bars = Array.from(chart.querySelectorAll(".bar-chart-col"));
|
||||
const maxValue = Math.max(0, ...bars.map((bar) => numberAttr(bar, "data-chart-value")));
|
||||
|
||||
bars.forEach((bar) => {
|
||||
const fill = bar.querySelector(".bar-chart-bar");
|
||||
if (!fill) {
|
||||
return;
|
||||
}
|
||||
const value = numberAttr(bar, "data-chart-value");
|
||||
let height = numberAttr(fill, "data-height-px");
|
||||
if (maxValue > 0) {
|
||||
height = value <= 0 ? 0 : Math.max(8, Math.round((value / maxValue) * maxBarHeight));
|
||||
}
|
||||
fill.style.height = `${Math.min(maxBarHeight, height)}px`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyStatusBars() {
|
||||
const rows = Array.from(document.querySelectorAll(".stat-bar"));
|
||||
const maxValue = Math.max(0, ...rows.map((row) => numberAttr(row, "data-stat-value")));
|
||||
|
||||
rows.forEach((row) => {
|
||||
const fill = row.querySelector(".stat-bar-fill");
|
||||
if (!fill) {
|
||||
return;
|
||||
}
|
||||
const value = numberAttr(row, "data-stat-value");
|
||||
let width = numberAttr(fill, "data-width-percent");
|
||||
if (maxValue > 0) {
|
||||
width = value <= 0 ? 0 : Math.round((value / maxValue) * 100);
|
||||
}
|
||||
fill.style.width = `${Math.max(0, Math.min(100, width))}%`;
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
applyChartBars();
|
||||
applyStatusBars();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -32,6 +32,7 @@
|
||||
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||
|
||||
@@ -65,9 +65,9 @@
|
||||
<p class="muted-copy">New boxes created over the last 14 days.</p>
|
||||
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
|
||||
{{range .Data.Overview.UploadDays}}
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
|
||||
<span class="bar-chart-value">{{.Value}}</span>
|
||||
<span class="bar-chart-bar" style="height: {{.Height}}%"></span>
|
||||
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
|
||||
<span class="bar-chart-label">{{.Label}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -81,9 +81,9 @@
|
||||
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
|
||||
<div class="stat-bars">
|
||||
{{range .Data.Overview.StatusBars}}
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar" data-stat-value="{{.RawValue}}">
|
||||
<span>{{.Label}} <strong>{{.Value}}</strong></span>
|
||||
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
|
||||
<span class="stat-bar-track"><span class="stat-bar-fill" data-width-percent="{{.WidthPercent}}" style="width: {{.WidthPercent}}%"></span></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -97,9 +97,9 @@
|
||||
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
|
||||
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
|
||||
{{range .Data.Overview.StorageDays}}
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
|
||||
<span class="bar-chart-value">{{.Value}}</span>
|
||||
<span class="bar-chart-bar" style="height: {{.Height}}%"></span>
|
||||
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
|
||||
<span class="bar-chart-label">{{.Label}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user