diff --git a/README.md b/README.md
index 7620f8c..98ba87a 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,8 @@ curl "${auth[@]}" 'https://ftp.example.com/api/stats?path=/public/example.mp4'
Other admin endpoints include `/api/users`, `/api/groups`, `/api/files`, `/api/files/action`, `/api/upload/chunk`, `/api/download`, `/api/fxp`, `/api/activity`, `/api/status`, `/api/doctor`, `/api/retention`, `/api/retention/restore`, and `/api/cloudflare/purge`.
+The authenticated `/api/doctor` response includes build provenance and uptime, effective HTTP timeouts, activity-buffer capacity and age, and individual storage/integration checks. Optional integrations that are disabled are reported as informational rather than failed. The activity dashboard scans the full in-memory history before filtering monitor traffic, so a busy probe loop cannot hide human or security events.
+
## Release Gate
Before a release candidate, run:
diff --git a/docs/large-transfers.md b/docs/large-transfers.md
index 7f8bf6f..ed5a6e4 100644
--- a/docs/large-transfers.md
+++ b/docs/large-transfers.md
@@ -10,6 +10,8 @@ The request history contained several failures at roughly 60 seconds followed by
Media clients commonly request a small tail range to inspect container metadata. macftpd records a partial response as a completed download only when it reaches EOF and transfers a meaningful amount: one percent of the object, capped at 8 MiB. A tiny tail probe therefore does not inflate download counts, while a substantial resume that completes the object does.
+If a browser, media player, or tunnel closes a response before it finishes, macftpd records the transfer as `canceled`. The byte count and range remain in the event for diagnosis, but the weekly report keeps these client-side interruptions separate from server failures.
+
## Integrity checks
For an end-to-end transfer test, compare the byte count and SHA-256 digest at the source and destination:
@@ -46,4 +48,6 @@ Stale chunk parts older than 24 hours are cleaned opportunistically. The upload,
- Confirm sufficient free space for the incoming staged file and, on overwrite, one retained copy of the previous destination.
- Compare the final byte count and digest for release or incident validation.
- Treat tiny EOF range requests as media probes; use the activity detail, status, bytes, and range fields to distinguish them from completed transfers.
+- Review `Client Cancellations` separately from `Failures` in the weekly report. Repeated cancellations at a consistent duration can still reveal a proxy or timeout problem, but isolated broken pipes and resets normally mean the client stopped reading.
+- The loopback FTP monitor identifies itself with `CLNT macftpd-monitor`. Intermediate successful probe actions are suppressed, one completed cycle is retained per hour, and every failed action is retained.
- When Cloudflare caching is enabled, configure a cache tag and public base URL. Public mutations purge the tag; HTTP mutations fall back to exact object and parent-listing URLs.
diff --git a/internal/activity/activity.go b/internal/activity/activity.go
index c5b7a59..b2752bb 100644
--- a/internal/activity/activity.go
+++ b/internal/activity/activity.go
@@ -36,6 +36,15 @@ type PathStats struct {
Recent []Event `json:"recent,omitempty"`
}
+type State struct {
+ Count int `json:"count"`
+ Capacity int `json:"capacity"`
+ OldestID int64 `json:"oldest_id,omitempty"`
+ NewestID int64 `json:"newest_id,omitempty"`
+ OldestTime time.Time `json:"oldest_time,omitempty"`
+ NewestTime time.Time `json:"newest_time,omitempty"`
+}
+
type Store struct {
mu sync.RWMutex
nextID int64
@@ -107,7 +116,7 @@ func (s *Store) Recent(limit int, afterID int64) []Event {
if s == nil {
return nil
}
- if limit <= 0 || limit > 500 {
+ if limit <= 0 {
limit = 100
}
s.mu.RLock()
@@ -123,6 +132,25 @@ func (s *Store) Recent(limit int, afterID int64) []Event {
return out
}
+func (s *Store) State() State {
+ if s == nil {
+ return State{}
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ state := State{Count: len(s.events), Capacity: s.limit}
+ if len(s.events) == 0 {
+ return state
+ }
+ oldest := s.events[0]
+ newest := s.events[len(s.events)-1]
+ state.OldestID = oldest.ID
+ state.NewestID = newest.ID
+ state.OldestTime = oldest.Time
+ state.NewestTime = newest.Time
+ return state
+}
+
func (s *Store) StatsForPath(path string, limit int) PathStats {
stats := PathStats{Path: path, Referrers: map[string]int{}}
if s == nil {
@@ -225,6 +253,11 @@ func (e Event) humanMessage() string {
return fmt.Sprintf("%s %s failed for %s", actor, action, subject)
}
return fmt.Sprintf("%s %s failed", actor, action)
+ case "canceled", "cancelled":
+ if subject != "" {
+ return fmt.Sprintf("%s %s canceled for %s", actor, action, subject)
+ }
+ return fmt.Sprintf("%s %s canceled", actor, action)
}
if e.DestPath != "" && subject != "" {
return fmt.Sprintf("%s %s %s to %s", actor, action, subject, e.DestPath)
diff --git a/internal/activity/activity_test.go b/internal/activity/activity_test.go
index c0c451d..04d3c9a 100644
--- a/internal/activity/activity_test.go
+++ b/internal/activity/activity_test.go
@@ -3,6 +3,7 @@ package activity
import (
"path/filepath"
"testing"
+ "time"
)
func TestFileStoreReloadsRecentEvents(t *testing.T) {
@@ -30,3 +31,22 @@ func TestFileStoreReloadsRecentEvents(t *testing.T) {
t.Fatalf("next ID = %d, want greater than %d", next.ID, events[0].ID)
}
}
+
+func TestStateTracksBoundedHistory(t *testing.T) {
+ store := New(2)
+ firstTime := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
+ store.Add(Event{Time: firstTime, Action: "first"})
+ second := store.Add(Event{Time: firstTime.Add(time.Minute), Action: "second"})
+ third := store.Add(Event{Time: firstTime.Add(2 * time.Minute), Action: "third"})
+
+ state := store.State()
+ if state.Count != 2 || state.Capacity != 2 {
+ t.Fatalf("state size = %#v, want 2/2", state)
+ }
+ if state.OldestID != second.ID || state.NewestID != third.ID {
+ t.Fatalf("state IDs = %#v, want oldest=%d newest=%d", state, second.ID, third.ID)
+ }
+ if !state.OldestTime.Equal(firstTime.Add(time.Minute)) || !state.NewestTime.Equal(firstTime.Add(2*time.Minute)) {
+ t.Fatalf("state times = %#v", state)
+ }
+}
diff --git a/internal/ftpserver/server.go b/internal/ftpserver/server.go
index 08875e3..9e30e49 100644
--- a/internal/ftpserver/server.go
+++ b/internal/ftpserver/server.go
@@ -49,6 +49,8 @@ type Server struct {
publicHook func(string)
readNoiseMu sync.Mutex
readNoise map[string]readNoiseEvent
+ monitorMu sync.Mutex
+ monitorLast time.Time
}
type readNoiseEvent struct {
@@ -59,6 +61,11 @@ type readNoiseEvent struct {
const readNoiseReportInterval = 10 * time.Minute
+const (
+ monitorClientName = "macftpd-monitor"
+ monitorSuccessInterval = time.Hour
+)
+
type session struct {
server *Server
conn net.Conn
@@ -78,6 +85,7 @@ type session struct {
restartSet bool
secure bool
protPrivate bool
+ monitor bool
statusID int64
}
@@ -336,11 +344,16 @@ func (s *session) dispatch(cmd, arg string) bool {
case "SYST":
s.reply(215, "UNIX Type: L8")
case "FEAT":
- features := []string{"UTF8", "EPSV", "PASV", "REST STREAM", "SIZE", "MDTM", "MLST type*;size*;modify*;perm*;", "MLSD"}
+ features := []string{"UTF8", "CLNT", "EPSV", "PASV", "REST STREAM", "SIZE", "MDTM", "MLST type*;size*;modify*;perm*;", "MLSD"}
if s.server.tlsConfig != nil {
features = append(features, "AUTH TLS", "PBSZ", "PROT")
}
s.multiline(211, features, "End")
+ case "CLNT":
+ if monitorClientAllowed(s.conn.RemoteAddr(), arg) {
+ s.monitor = true
+ }
+ s.reply(200, "Client name noted")
case "OPTS":
s.reply(200, "OK")
case "PWD", "XPWD":
@@ -1467,6 +1480,15 @@ func (s *session) loginLimitKey() string {
}
func (s *session) logActivity(action, outcome, pathValue, destPath string, bytes int64, detail string) {
+ if s.monitor && outcome == "ok" {
+ // A successful cleanup is the end-to-end monitor signal. Keep one per
+ // hour and discard intermediate login/mkdir/upload/download successes.
+ // Failures are never coalesced.
+ if action != "delete" || !s.server.monitorSuccessDue() {
+ return
+ }
+ detail = "FTP monitor cycle completed"
+ }
actor := s.username
if s.user.Username != "" {
actor = s.user.Username
@@ -1488,6 +1510,32 @@ func (s *session) logActivity(action, outcome, pathValue, destPath string, bytes
})
}
+func monitorClientAllowed(remote net.Addr, name string) bool {
+ if !strings.EqualFold(strings.TrimSpace(name), monitorClientName) || remote == nil {
+ return false
+ }
+ if tcp, ok := remote.(*net.TCPAddr); ok {
+ return tcp.IP.IsLoopback()
+ }
+ host, _, err := net.SplitHostPort(remote.String())
+ if err != nil {
+ host = remote.String()
+ }
+ ip := net.ParseIP(strings.Trim(host, "[]"))
+ return ip != nil && ip.IsLoopback()
+}
+
+func (s *Server) monitorSuccessDue() bool {
+ s.monitorMu.Lock()
+ defer s.monitorMu.Unlock()
+ now := time.Now()
+ if !s.monitorLast.IsZero() && now.Sub(s.monitorLast) < monitorSuccessInterval {
+ return false
+ }
+ s.monitorLast = now
+ return true
+}
+
func (s *session) updateStatus(mutate func(*status.Session)) {
if s.server.tracker == nil || s.statusID == 0 {
return
diff --git a/internal/ftpserver/server_test.go b/internal/ftpserver/server_test.go
index 5befb20..a9c1027 100644
--- a/internal/ftpserver/server_test.go
+++ b/internal/ftpserver/server_test.go
@@ -1,6 +1,7 @@
package ftpserver
import (
+ "bufio"
"bytes"
"context"
"crypto/ecdsa"
@@ -50,6 +51,64 @@ func TestPassiveDataPeerMustMatchControlPeer(t *testing.T) {
}
}
+func TestFTPMonitorMarkerRequiresLoopback(t *testing.T) {
+ loopback := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000}
+ external := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 50000}
+ if !monitorClientAllowed(loopback, "macftpd-monitor") {
+ t.Fatal("loopback monitor marker was denied")
+ }
+ if monitorClientAllowed(external, "macftpd-monitor") {
+ t.Fatal("external client was allowed to suppress monitor activity")
+ }
+ if monitorClientAllowed(loopback, "ordinary-client") {
+ t.Fatal("ordinary client was treated as the monitor")
+ }
+
+ left, right := net.Pipe()
+ defer left.Close()
+ defer right.Close()
+ conn := &testRemoteConn{Conn: left, remote: loopback}
+ ss := &session{server: &Server{}, conn: conn, writer: bufio.NewWriter(io.Discard)}
+ ss.dispatch("CLNT", "macftpd-monitor")
+ if !ss.monitor {
+ t.Fatal("CLNT did not mark the loopback monitor session")
+ }
+}
+
+func TestFTPMonitorSuccessesAreCoalescedAfterCompletedCycle(t *testing.T) {
+ activityLog := activity.New(100)
+ server := &Server{activity: activityLog}
+ left, right := net.Pipe()
+ defer left.Close()
+ defer right.Close()
+ ss := &session{server: server, conn: left, username: "admin", monitor: true}
+
+ for _, action := range []string{"login", "mkdir", "upload", "download"} {
+ ss.logActivity(action, "ok", "/_monitor/probe.txt", "", 10, "probe step")
+ }
+ if events := activityLog.Recent(10, 0); len(events) != 0 {
+ t.Fatalf("intermediate monitor successes were logged: %#v", events)
+ }
+ ss.logActivity("delete", "ok", "/_monitor/probe.txt", "", 0, "cleanup")
+ ss.logActivity("delete", "ok", "/_monitor/probe-2.txt", "", 0, "cleanup")
+ events := activityLog.Recent(10, 0)
+ if len(events) != 1 || events[0].Outcome != "ok" || events[0].Detail != "FTP monitor cycle completed" {
+ t.Fatalf("completed monitor cycle was not coalesced: %#v", events)
+ }
+ ss.logActivity("download", "failed", "/_monitor/probe.txt", "", 3, "connection reset")
+ events = activityLog.Recent(10, 0)
+ if len(events) != 2 || events[0].Outcome != "failed" {
+ t.Fatalf("monitor failure was suppressed: %#v", events)
+ }
+}
+
+type testRemoteConn struct {
+ net.Conn
+ remote net.Addr
+}
+
+func (c *testRemoteConn) RemoteAddr() net.Addr { return c.remote }
+
func TestFTPActiveSessionRevalidatesDisabledUser(t *testing.T) {
dir := t.TempDir()
store, err := auth.Open(dir + "/users.json")
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 787616c..69d9473 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -20,10 +20,13 @@ import (
"os"
"path"
"path/filepath"
+ "runtime"
+ "runtime/debug"
"sort"
"strconv"
"strings"
"sync"
+ "syscall"
"time"
ftpclient "github.com/jlaffaye/ftp"
@@ -52,6 +55,7 @@ type Server struct {
tracker *status.Tracker
uploadMu sync.Mutex
uploads map[string]*uploadLock
+ startedAt time.Time
}
type uploadLock struct {
@@ -108,7 +112,7 @@ func (r userRequest) user() auth.User {
}
func New(cfg config.HTTPConfig, store *auth.Store, root *storage.Root, cf *cloudflare.Client, activityLog *activity.Store, links *share.Store, tracker *status.Tracker) *Server {
- return &Server{cfg: cfg, store: store, root: root, cloudflare: cf, sessionKey: []byte(cfg.SessionKey), limiter: ratelimit.New(5, 10*time.Minute, 5*time.Minute), shareLimit: ratelimit.New(5, 10*time.Minute, 5*time.Minute), activity: activityLog, links: links, tracker: tracker, uploads: map[string]*uploadLock{}}
+ return &Server{cfg: cfg, store: store, root: root, cloudflare: cf, sessionKey: []byte(cfg.SessionKey), limiter: ratelimit.New(5, 10*time.Minute, 5*time.Minute), shareLimit: ratelimit.New(5, 10*time.Minute, 5*time.Minute), activity: activityLog, links: links, tracker: tracker, uploads: map[string]*uploadLock{}, startedAt: time.Now().UTC()}
}
func (s *Server) ListenAndServe(ctx context.Context) error {
@@ -1877,13 +1881,10 @@ func (s *Server) activityDashboard(limit int, after int64) activityDashboard {
if limit <= 0 || limit > 200 {
limit = 80
}
- scanLimit := limit * 6
- if scanLimit < 200 {
- scanLimit = 200
- }
- if scanLimit > 500 {
- scanLimit = 500
- }
+ // The production activity buffer holds 2,000 events. Scan it in full so a
+ // burst of monitor or maintenance traffic cannot hide human activity and
+ // security events from the filtered dashboard.
+ const scanLimit = 2000
dashboard := activityDashboard{
Events: []activity.Event{},
Security: []activity.Event{},
@@ -2010,34 +2011,102 @@ func (s *Server) doctorAPI(w http.ResponseWriter, r *http.Request, _ principal)
writeJSON(w, http.StatusMethodNotAllowed, errorBody("method not allowed"))
return
}
- writeJSON(w, http.StatusOK, map[string]any{"checks": s.doctorChecks(), "time": time.Now().UTC()})
+ payload := map[string]any{
+ "checks": s.doctorChecks(),
+ "http": s.doctorHTTPStatus(),
+ "runtime": s.doctorRuntimeStatus(),
+ "time": time.Now().UTC(),
+ }
+ if s.activity != nil {
+ payload["activity"] = s.activity.State()
+ }
+ writeJSON(w, http.StatusOK, payload)
}
func (s *Server) doctorChecks() []map[string]any {
checks := []map[string]any{}
- add := func(name string, ok bool, detail string) {
- checks = append(checks, map[string]any{"name": name, "ok": ok, "detail": detail})
+ add := func(name, level, detail string) {
+ checks = append(checks, map[string]any{"name": name, "ok": level != "fail", "level": level, "detail": detail})
}
if info, err := os.Stat(s.root.Base); err == nil && info.IsDir() {
- add("storage root", true, s.root.Base)
+ add("storage root", "ok", s.root.Base)
} else {
- add("storage root", false, fmt.Sprint(err))
+ add("storage root", "fail", fmt.Sprint(err))
}
for _, dir := range []string{s.root.PublicDir, s.root.DropboxDir, "._macftpd_trash", "._macftpd_versions"} {
real := filepath.Join(s.root.Base, dir)
if err := os.MkdirAll(real, 0o750); err != nil {
- add("storage "+dir, false, err.Error())
+ add("storage "+dir, "fail", err.Error())
} else {
- add("storage "+dir, true, real)
+ add("storage "+dir, "ok", real)
+ }
+ }
+ cloudflareLevel := "info"
+ if s.cloudflare.Enabled() {
+ cloudflareLevel = "ok"
+ }
+ add("cloudflare client", cloudflareLevel, "optional; configured="+strconv.FormatBool(s.cloudflare.Enabled()))
+ if s.links == nil {
+ add("share store", "fail", "unavailable")
+ } else {
+ add("share store", "ok", strconv.Itoa(len(s.links.List()))+" links")
+ }
+ if s.activity == nil {
+ add("activity store", "fail", "unavailable")
+ } else {
+ state := s.activity.State()
+ detail := fmt.Sprintf("%d/%d events", state.Count, state.Capacity)
+ if !state.OldestTime.IsZero() {
+ detail += "; oldest=" + state.OldestTime.UTC().Format(time.RFC3339)
}
+ add("activity store", "ok", detail)
+ }
+ streamLevel := "ok"
+ if s.cfg.ReadTimeout != 0 || s.cfg.WriteTimeout != 0 {
+ streamLevel = "warning"
+ }
+ add("HTTP streaming", streamLevel, fmt.Sprintf("read_timeout=%s write_timeout=%s", time.Duration(s.cfg.ReadTimeout), time.Duration(s.cfg.WriteTimeout)))
+ turnstileLevel := "info"
+ if s.cfg.TurnstileSecret != "" {
+ turnstileLevel = "ok"
}
- add("cloudflare client", s.cloudflare.Enabled(), "configured="+strconv.FormatBool(s.cloudflare.Enabled()))
- add("share store", s.links != nil, strconv.Itoa(len(s.links.List()))+" links")
- add("activity store", s.activity != nil, "ready")
- add("turnstile", s.cfg.TurnstileSecret != "", "configured="+strconv.FormatBool(s.cfg.TurnstileSecret != ""))
+ add("turnstile", turnstileLevel, "optional; configured="+strconv.FormatBool(s.cfg.TurnstileSecret != ""))
return checks
}
+func (s *Server) doctorHTTPStatus() map[string]string {
+ return map[string]string{
+ "read_header_timeout": s.cfg.ReadHeaderTimeout.Std(10 * time.Second).String(),
+ "read_timeout": time.Duration(s.cfg.ReadTimeout).String(),
+ "write_timeout": time.Duration(s.cfg.WriteTimeout).String(),
+ "idle_timeout": s.cfg.IdleTimeout.Std(60 * time.Second).String(),
+ }
+}
+
+func (s *Server) doctorRuntimeStatus() map[string]any {
+ status := map[string]any{
+ "go_version": runtime.Version(),
+ "started_at": s.startedAt,
+ }
+ if !s.startedAt.IsZero() {
+ status["uptime_seconds"] = int64(time.Since(s.startedAt).Seconds())
+ }
+ if info, ok := debug.ReadBuildInfo(); ok {
+ status["module_version"] = info.Main.Version
+ for _, setting := range info.Settings {
+ switch setting.Key {
+ case "vcs.revision":
+ status["vcs_revision"] = setting.Value
+ case "vcs.time":
+ status["vcs_time"] = setting.Value
+ case "vcs.modified":
+ status["vcs_modified"] = setting.Value == "true"
+ }
+ }
+ }
+ return status
+}
+
func (s *Server) sharesAPI(w http.ResponseWriter, r *http.Request, p principal) {
switch r.Method {
case http.MethodGet:
@@ -2593,7 +2662,15 @@ func (w *countingResponseWriter) Unwrap() http.ResponseWriter {
func (s *Server) logDownloadActivity(event activity.Event, result fileServeResult) {
event.Bytes = result.Bytes
- if result.Err != nil || result.Status >= 400 {
+ if result.Err != nil && isClientDisconnect(result.Err) {
+ event.Outcome = "canceled"
+ detail := fmt.Sprintf("%s canceled status=%d bytes=%d", event.Detail, result.Status, result.Bytes)
+ if result.Range != "" {
+ detail += " range=" + result.Range
+ }
+ detail += " error=" + result.Err.Error()
+ event.Detail = detail
+ } else if result.Err != nil || result.Status >= 400 {
event.Outcome = "failed"
detail := fmt.Sprintf("%s failed status=%d bytes=%d", event.Detail, result.Status, result.Bytes)
if result.Range != "" {
@@ -2616,6 +2693,31 @@ func (s *Server) logDownloadActivity(event activity.Event, result fileServeResul
s.logActivity(event)
}
+func isClientDisconnect(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) ||
+ errors.Is(err, io.ErrClosedPipe) || errors.Is(err, syscall.EPIPE) ||
+ errors.Is(err, syscall.ECONNRESET) {
+ return true
+ }
+ message := strings.ToLower(err.Error())
+ for _, signal := range []string{
+ "broken pipe",
+ "connection reset by peer",
+ "client disconnected",
+ "stream canceled",
+ "stream cancelled",
+ "; cancel",
+ } {
+ if strings.Contains(message, signal) {
+ return true
+ }
+ }
+ return false
+}
+
func rangeRunsToEOF(header string, size int64) bool {
if size <= 0 {
return false
diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go
index d85aeed..02a0aba 100644
--- a/internal/httpapi/server_test.go
+++ b/internal/httpapi/server_test.go
@@ -3,6 +3,7 @@ package httpapi
import (
"bytes"
"encoding/json"
+ "errors"
"html/template"
"mime/multipart"
"net/http"
@@ -11,6 +12,7 @@ import (
"os"
"strconv"
"strings"
+ "syscall"
"testing"
"time"
@@ -54,6 +56,35 @@ func TestTinyTailProbeDoesNotCountAsCompletedLargeDownload(t *testing.T) {
}
}
+func TestClientDisconnectsAreCanceledRatherThanFailed(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ err error
+ want bool
+ }{
+ {name: "broken pipe", err: syscall.EPIPE, want: true},
+ {name: "reset", err: syscall.ECONNRESET, want: true},
+ {name: "HTTP/2 cancel", err: errors.New("stream error: stream ID 7; CANCEL"), want: true},
+ {name: "storage failure", err: errors.New("storage read failed"), want: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isClientDisconnect(tc.err); got != tc.want {
+ t.Fatalf("isClientDisconnect(%v) = %v, want %v", tc.err, got, tc.want)
+ }
+ })
+ }
+
+ srv := testServer(t)
+ srv.logDownloadActivity(activity.Event{
+ Type: "share_download", Protocol: "http", Actor: "share-link",
+ Action: "download", Path: "/public/movie.mkv", Detail: "public share download",
+ }, fileServeResult{Status: http.StatusOK, Bytes: 4 << 20, Method: http.MethodGet, Err: syscall.EPIPE})
+ events := srv.activity.Recent(10, 0)
+ if len(events) != 1 || events[0].Outcome != "canceled" || !strings.Contains(events[0].Message, "canceled") || isSecurityActivity(events[0]) {
+ t.Fatalf("client disconnect was not recorded as a non-failure cancellation: %#v", events)
+ }
+}
+
func TestAdminFilesURLRemainsSafeInTemplateURLContext(t *testing.T) {
tmpl := template.Must(template.New("url").Funcs(templateFuncs()).Parse(`open`))
var rendered strings.Builder
@@ -272,6 +303,69 @@ func TestActivityDashboardSuppressesMonitorAndSeparatesSecurity(t *testing.T) {
}
}
+func TestActivityDashboardScansPastMonitorFlood(t *testing.T) {
+ srv := testServer(t)
+ srv.activity = activity.New(2000)
+ human := srv.activity.Add(activity.Event{Type: "admin_file", Protocol: "http", Actor: "admin", Action: "copy", Path: "/public/important.txt"})
+ for i := 0; i < 700; i++ {
+ srv.activity.Add(activity.Event{Type: "ftp_delete", Protocol: "ftp", Actor: "admin", Remote: "127.0.0.1:50000", Action: "delete", Path: "_monitor/probe.txt", Detail: "FTP monitor cleanup"})
+ }
+
+ dashboard := srv.activityDashboard(20, 0)
+ if dashboard.Monitor.Count != 700 {
+ t.Fatalf("monitor count = %d, want 700", dashboard.Monitor.Count)
+ }
+ if len(dashboard.Events) != 1 || dashboard.Events[0].ID != human.ID {
+ t.Fatalf("human event was hidden by monitor traffic: %#v", dashboard.Events)
+ }
+}
+
+func TestDoctorReportsOperationalMetadataAndOptionalChecks(t *testing.T) {
+ srv := testServer(t)
+ srv.cfg.WriteTimeout = config.Duration(time.Minute)
+ srv.activity.Add(activity.Event{Action: "test"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/doctor", nil)
+ req.SetBasicAuth("admin", "secret")
+ rr := httptest.NewRecorder()
+ srv.requireAdmin(srv.doctorAPI)(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("doctor status = %d body=%s", rr.Code, rr.Body.String())
+ }
+ var body struct {
+ Checks []struct {
+ Name string `json:"name"`
+ OK bool `json:"ok"`
+ Level string `json:"level"`
+ } `json:"checks"`
+ HTTP map[string]string `json:"http"`
+ Runtime map[string]any `json:"runtime"`
+ Activity activity.State `json:"activity"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode doctor response: %v", err)
+ }
+ if body.HTTP["write_timeout"] != "1m0s" || body.HTTP["read_timeout"] != "0s" {
+ t.Fatalf("unexpected HTTP timeout metadata: %#v", body.HTTP)
+ }
+ if body.Runtime["go_version"] == "" || body.Runtime["started_at"] == nil {
+ t.Fatalf("missing runtime metadata: %#v", body.Runtime)
+ }
+ if body.Activity.Count != 1 || body.Activity.Capacity != 200 {
+ t.Fatalf("unexpected activity state: %#v", body.Activity)
+ }
+ levels := map[string]string{}
+ for _, check := range body.Checks {
+ if !check.OK {
+ t.Fatalf("non-failing doctor check reported ok=false: %#v", check)
+ }
+ levels[check.Name] = check.Level
+ }
+ if levels["HTTP streaming"] != "warning" || levels["cloudflare client"] != "info" || levels["turnstile"] != "info" {
+ t.Fatalf("unexpected doctor check levels: %#v", levels)
+ }
+}
+
func TestUploadRejectsIgnoredDestination(t *testing.T) {
srv := testServer(t)
var body bytes.Buffer
diff --git a/internal/httpapi/templates/partial_status.html b/internal/httpapi/templates/partial_status.html
index 1a8cb47..1c1ebd4 100644
--- a/internal/httpapi/templates/partial_status.html
+++ b/internal/httpapi/templates/partial_status.html
@@ -13,7 +13,16 @@
Status
{{index . "name"}}
{{index . "detail"}}
- {{if index . "ok"}}ok{{else}}fail{{end}}
+ {{$level := index . "level"}}
+ {{if eq $level "fail"}}
+ fail
+ {{else if eq $level "warning"}}
+ warn
+ {{else if eq $level "info"}}
+ optional
+ {{else}}
+ ok
+ {{end}}
{{end}}
diff --git a/package-lock.json b/package-lock.json
index 63a2d8d..769c23e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"daisyui": "^4.12.24",
"htmx.org": "^2.0.10",
"tailwindcss": "^3.4.19",
- "wrangler": "4.110.0"
+ "wrangler": "4.113.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -55,9 +55,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-64": {
- "version": "1.20260708.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260708.1.tgz",
- "integrity": "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==",
+ "version": "1.20260721.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz",
+ "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==",
"cpu": [
"x64"
],
@@ -72,9 +72,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-arm64": {
- "version": "1.20260708.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260708.1.tgz",
- "integrity": "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==",
+ "version": "1.20260721.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz",
+ "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==",
"cpu": [
"arm64"
],
@@ -89,9 +89,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-64": {
- "version": "1.20260708.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260708.1.tgz",
- "integrity": "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==",
+ "version": "1.20260721.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz",
+ "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==",
"cpu": [
"x64"
],
@@ -106,9 +106,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-arm64": {
- "version": "1.20260708.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260708.1.tgz",
- "integrity": "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==",
+ "version": "1.20260721.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz",
+ "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==",
"cpu": [
"arm64"
],
@@ -123,9 +123,9 @@
}
},
"node_modules/@cloudflare/workerd-windows-64": {
- "version": "1.20260708.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260708.1.tgz",
- "integrity": "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==",
+ "version": "1.20260721.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz",
+ "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==",
"cpu": [
"x64"
],
@@ -627,9 +627,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -640,19 +640,19 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -663,19 +663,39 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -690,9 +710,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -707,9 +727,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -727,9 +747,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -747,9 +767,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
@@ -767,9 +787,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
@@ -787,9 +807,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -807,9 +827,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -827,9 +847,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -847,9 +867,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -867,9 +887,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -883,19 +903,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
+ "@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -909,19 +929,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
@@ -935,19 +955,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
@@ -961,19 +981,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -987,19 +1007,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -1013,19 +1033,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
+ "@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -1039,19 +1059,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -1065,39 +1085,56 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"dev": true,
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "license": "Apache-2.0",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.7.0"
+ "@img/sharp-wasm32": "0.35.3"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
@@ -1108,16 +1145,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -1128,16 +1165,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -1148,7 +1185,7 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
@@ -1798,16 +1835,16 @@
}
},
"node_modules/miniflare": {
- "version": "4.20260708.1",
- "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260708.1.tgz",
- "integrity": "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==",
+ "version": "4.20260721.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz",
+ "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "0.8.1",
"sharp": "0.34.5",
"undici": "7.28.0",
- "workerd": "1.20260708.1",
+ "workerd": "1.20260721.1",
"ws": "8.21.0",
"youch": "4.1.0-beta.10"
},
@@ -2218,48 +2255,53 @@
}
},
"node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"dev": true,
- "hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@img/colour": "^1.0.0",
+ "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
- "semver": "^7.7.3"
+ "semver": "^7.8.5"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/source-map-js": {
@@ -2486,9 +2528,9 @@
"license": "MIT"
},
"node_modules/workerd": {
- "version": "1.20260708.1",
- "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260708.1.tgz",
- "integrity": "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==",
+ "version": "1.20260721.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz",
+ "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -2499,17 +2541,17 @@
"node": ">=16"
},
"optionalDependencies": {
- "@cloudflare/workerd-darwin-64": "1.20260708.1",
- "@cloudflare/workerd-darwin-arm64": "1.20260708.1",
- "@cloudflare/workerd-linux-64": "1.20260708.1",
- "@cloudflare/workerd-linux-arm64": "1.20260708.1",
- "@cloudflare/workerd-windows-64": "1.20260708.1"
+ "@cloudflare/workerd-darwin-64": "1.20260721.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260721.1",
+ "@cloudflare/workerd-linux-64": "1.20260721.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260721.1",
+ "@cloudflare/workerd-windows-64": "1.20260721.1"
}
},
"node_modules/wrangler": {
- "version": "4.110.0",
- "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.110.0.tgz",
- "integrity": "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==",
+ "version": "4.113.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz",
+ "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
@@ -2517,10 +2559,10 @@
"@cloudflare/unenv-preset": "2.16.1",
"blake3-wasm": "2.1.5",
"esbuild": "0.28.1",
- "miniflare": "4.20260708.1",
+ "miniflare": "4.20260721.0",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
- "workerd": "1.20260708.1"
+ "workerd": "1.20260721.1"
},
"bin": {
"cf-wrangler": "bin/cf-wrangler.js",
@@ -2534,7 +2576,7 @@
"fsevents": "2.3.3"
},
"peerDependencies": {
- "@cloudflare/workers-types": "^5.20260708.1"
+ "@cloudflare/workers-types": "^5.20260721.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
diff --git a/package.json b/package.json
index 4379ec5..a55ef6a 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,10 @@
"daisyui": "^4.12.24",
"htmx.org": "^2.0.10",
"tailwindcss": "^3.4.19",
- "wrangler": "4.110.0"
+ "wrangler": "4.113.0"
+ },
+ "overrides": {
+ "sharp": "0.35.3"
},
"scripts": {
"build:css": "tailwindcss -i ./internal/httpapi/assets/app.css -o ./internal/httpapi/static/macftpd.css --minify",
diff --git a/scripts/check.sh b/scripts/check.sh
index 11542b9..12bebc9 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -31,6 +31,7 @@ go build -o "${build_dir}/macftpd" ./cmd/macftpd
GOOS=darwin GOARCH=arm64 go build -o "${build_dir}/macftpd-darwin-arm64" ./cmd/macftpd
npm ci
+npm audit --audit-level=high
cp internal/httpapi/static/macftpd.css internal/httpapi/static/htmx.min.js "${asset_snapshot}/"
npm run build
diff -u "${asset_snapshot}/macftpd.css" internal/httpapi/static/macftpd.css
diff --git a/scripts/monitor.sh b/scripts/monitor.sh
index 0b0e7cf..23f2fcb 100755
--- a/scripts/monitor.sh
+++ b/scripts/monitor.sh
@@ -52,6 +52,7 @@ remote_file = f"{remote_dir}/{stamp}.txt"
ftp = ftplib.FTP()
ftp.connect(host, port, timeout=20)
+ftp.sendcmd("CLNT macftpd-monitor")
ftp.login(user, password)
try:
ftp.mkd(remote_dir)
diff --git a/scripts/protocol-lab.sh b/scripts/protocol-lab.sh
index 878301c..697d08d 100755
--- a/scripts/protocol-lab.sh
+++ b/scripts/protocol-lab.sh
@@ -45,7 +45,7 @@ ensure_dir(ftp, "_protocol_lab")
ensure_dir(ftp, base)
ftp.voidcmd("TYPE I")
features = "\n".join(ftp.sendcmd("FEAT").splitlines())
-required = ["UTF8", "PASV", "EPSV", "REST STREAM", "SIZE", "MDTM", "MLSD"]
+required = ["UTF8", "CLNT", "PASV", "EPSV", "REST STREAM", "SIZE", "MDTM", "MLSD"]
missing = [x for x in required if x not in features]
if missing:
raise SystemExit(f"missing FEAT entries: {missing}")
diff --git a/scripts/weekly-report.sh b/scripts/weekly-report.sh
index 31810ba..22d7484 100755
--- a/scripts/weekly-report.sh
+++ b/scripts/weekly-report.sh
@@ -74,6 +74,7 @@ paths.extend(sorted(var_dir.glob("activity.jsonl.*.gz")))
counts = collections.Counter()
monitor_counts = collections.Counter()
failures = collections.Counter()
+cancellations = collections.Counter()
monitor_failures = collections.Counter()
bytes_by_action = collections.Counter()
paths_by_action = collections.Counter()
@@ -175,7 +176,10 @@ for path in paths:
bytes_by_action[action] += int(size or 0)
except (TypeError, ValueError):
pass
- if outcome not in ("ok", "success"):
+ if outcome in ("canceled", "cancelled"):
+ if not monitor and not maintenance:
+ cancellations[(action, event.get("detail") or "canceled")] += 1
+ elif outcome not in ("ok", "success"):
if monitor:
monitor_failures[(action, event.get("detail") or "failed")] += 1
elif not maintenance:
@@ -203,6 +207,11 @@ if failures:
for (action, detail), count in failures.most_common(10):
detail = str(detail).replace("`", "'")
print(f"- {action}: `{count}` `{detail[:160]}`")
+if cancellations:
+ print("\n### Client Cancellations\n")
+ for (action, detail), count in cancellations.most_common(10):
+ detail = str(detail).replace("`", "'")
+ print(f"- {action}: `{count}` `{detail[:160]}`")
if monitor_counts:
print("\n### Monitor Summary\n")
for (action, status), count in monitor_counts.most_common():