diff --git a/README.md b/README.md index f1adb3d..b2a66da 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ a web UI for sharing text and small files. - Cookie based saved pastes and settings - Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant - Dark mode toggle in the topbar and settings, with a configurable default +- Polished code viewer: line-number gutter sized to the widest number and pinned during horizontal scroll, optional line wrap, jump-to-top/bottom buttons, and theme-aware scrollbars ## Screenshots @@ -30,6 +31,7 @@ a web UI for sharing text and small files. | ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) | + ## Get Started ### Docker @@ -64,10 +66,11 @@ go build -o palette ./cmd/palette | `PALETTE_ADDR` | `:8080` | Listen address | | `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | -| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) | +| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) | | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `/admin-key` (0600) | | `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. | | `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. | +| `PALETTE_TRUSTED_IP_HEADER` | unset | Name of a proxy-controlled client-IP header to key API rate limits on (e.g. `CF-Connecting-IP` when Cloudflare is the ingress; Cloudflare strips any client-supplied value). Unset: rate limits key on the peer address only, and all client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored. (#280) | An `/admin` page exists for runtime settings, protected by a key set at install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See @@ -99,4 +102,4 @@ data loss, or legal issues arising from use of the software. You use it at your own risk. If you run a modified version of Palette as a network service, the AGPL -requires you to offer your modified source code to its users. +requires you to offer your modified source code to its users. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 068f90a..65bfb8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,10 +32,16 @@ services: # Default: 5242880 (5 MiB). # PALETTE_MAX_TEXT: "5242880" - # Max size in bytes of a single can item (file/text inside a can). + # Max size in bytes of a single can item (file/text inside a can) or a + # paste file attachment. # Default: 26214400 (25 MiB). # PALETTE_MAX_ITEM: "26214400" + # Default dark mode for new visitors. Unset = dark on; set to "false", + # "0" or "off" to default to light mode. Visitors who toggle dark mode + # keep their choice in their browser. + # PALETTE_DEFAULT_DARK: "false" + # HMAC secret for password-unlock cookies. Default: random per start, # which logs out every unlocked browser session on restart. Set a fixed # secret (any random string) to keep unlock sessions across restarts, diff --git a/internal/api/attachments.go b/internal/api/attachments.go index 63fca22..862f544 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -91,17 +91,6 @@ func (l *limitReader) Read(p []byte) (int, error) { return n, err } -// isImageMime reports whether the sniffed mime is a raster image the viewer -// can render inline (#221). SVG is excluded: it is forced to text/plain on -// serving by the active-content rule and must never render as an image. -func isImageMime(mime string) bool { - switch mime { - case "image/png", "image/jpeg", "image/gif", "image/webp": - return true - } - return false -} - // handleCreatePasteMultipart implements POST /api/pastes with // multipart/form-data (#38). Fields mirror the JSON create path; a 'file' // part makes the paste a file paste (1 file = 1 paste: if text content is diff --git a/internal/api/attachments_test.go b/internal/api/attachments_test.go index c9840cc..7b96ac1 100644 --- a/internal/api/attachments_test.go +++ b/internal/api/attachments_test.go @@ -306,3 +306,64 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) { t.Fatalf("paste should require password, got %d", rec2.Code) } } + +// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes, +// not just raster images (the old isImageMime gate left non-image +// attachments serving an empty body from row.Content). +func TestRawStreamsNonImageAttachment(t *testing.T) { + s := testServer(t) + h := s.routes() + + body := []byte("hello, this is a plain text attachment body") + rec, resp := multipartCreate(t, h, "notes.txt", body, nil) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + if resp["attachment"] == nil { + t.Fatalf("no attachment in response: %v", resp) + } + id, _ := resp["id"].(string) + + req := httptest.NewRequest("GET", "/raw/"+id, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != 200 { + t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String()) + } + if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q", got) + } + if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("nosniff = %q", got) + } + if !bytes.Equal(rec2.Body.Bytes(), body) { + t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body)) + } +} + +// #281: active-content attachment types still get forced to text/plain on +// /raw, same rule as the /f/ serving path (#34). +func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) { + s := testServer(t) + h := s.routes() + + html := []byte("") + rec, resp := multipartCreate(t, h, "page.html", html, nil) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + id, _ := resp["id"].(string) + + req := httptest.NewRequest("GET", "/raw/"+id, nil) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req) + if rec2.Code != 200 { + t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String()) + } + if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q", got) + } + if !bytes.Equal(rec2.Body.Bytes(), html) { + t.Fatal("raw bytes differ from upload") + } +} diff --git a/internal/api/clientip.go b/internal/api/clientip.go new file mode 100644 index 0000000..82ae13b --- /dev/null +++ b/internal/api/clientip.go @@ -0,0 +1,57 @@ +// clientIP extracts the client IP for rate-limit keying. +// +// Trust boundary (issue #280): the bucket key MUST NOT come from any header a +// client can influence. The previous rightmost-X-Forwarded-For scheme (#85) +// assumed Traefik appends the real client IP, but the deployed ingress does +// not rewrite XFF, so a client rotating its own XFF value got a fresh bucket +// per request and the limit was unenforceable (pentest H1: 6x201 across 8 +// rotating-XFF creates). +// +// Default: key on the actual peer address (RemoteAddr) only. Behind any +// reverse proxy this is the proxy's address, so all clients share one bucket +// per endpoint — coarse, but safe. +// +// Proxy-honoring mode: a deployment in front of a proxy that OVERWRITES (not +// appends to) a client-IP header can set PALETTE_TRUSTED_IP_HEADER (e.g. +// CF-Connecting-IP when Cloudflare is the ingress; Cloudflare strips any +// client-supplied value). The header is honored ONLY when explicitly +// configured at startup, and X-Forwarded-For / X-Real-Ip are never trusted. +package api + +import ( + "net" + "net/http" + "sync" +) + +var ( + trustedIPMu sync.RWMutex + trustedIPHeader string // empty = never trust any client-IP header +) + +// SetTrustedIPHeader configures the single proxy-controlled header whose +// value may key rate-limit buckets. Called at startup; tests may reset it. +func SetTrustedIPHeader(name string) { + trustedIPMu.Lock() + defer trustedIPMu.Unlock() + trustedIPHeader = name +} + +func getTrustedIPHeader() string { + trustedIPMu.RLock() + defer trustedIPMu.RUnlock() + return trustedIPHeader +} + +func clientIP(r *http.Request) string { + if name := getTrustedIPHeader(); name != "" { + if v := r.Header.Get(name); v != "" { + return v + } + } + host := r.RemoteAddr + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + host = h + } + return host +} diff --git a/internal/api/issue138_cookie_test.go b/internal/api/issue138_cookie_test.go index d5e8ca2..93459be 100644 --- a/internal/api/issue138_cookie_test.go +++ b/internal/api/issue138_cookie_test.go @@ -29,7 +29,7 @@ func newTestServer138(t *testing.T) *httptest.ResponseRecorder { globalSettingsFn = ss.get t.Cleanup(func() { globalSettingsFn = nil }) a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} - req := httptest.NewRequest("GET", "/history", nil) + req := httptest.NewRequest("GET", "/public", nil) rec := httptest.NewRecorder() a.routes().ServeHTTP(rec, req) return rec diff --git a/internal/api/mine_test.go b/internal/api/mine_test.go index 233e5a5..c3adcfd 100644 --- a/internal/api/mine_test.go +++ b/internal/api/mine_test.go @@ -58,7 +58,7 @@ func TestMineCreateListDelete(t *testing.T) { a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} h := a.routes() - alice := viewerCookieFor(t, h, "/history") + alice := viewerCookieFor(t, h, "/public") if alice == "" { t.Fatal("no viewer cookie issued") } @@ -89,7 +89,7 @@ func TestMineCreateListDelete(t *testing.T) { } // a different browser's cookie does NOT see it - bob := viewerCookieFor(t, h, "/history") + bob := viewerCookieFor(t, h, "/public") rec = doReq(t, h, "GET", "/api/mine", bob, "") json.Unmarshal(rec.Body.Bytes(), &list) if list.Total != 0 { diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 7c44bd6..b01f9a2 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -3,7 +3,6 @@ package api import ( "net/http" "strconv" - "strings" "sync" "time" ) @@ -48,35 +47,6 @@ func (l *limiter) allow(key string, rate, burst float64) bool { return true } -// clientIP extracts the client IP for rate-limit keying (#85). -// -// Trust boundary: palette runs behind exactly ONE trusted reverse proxy -// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to -// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted -// proxy observed and is unspoofable by the client (a client-supplied fake -// entry only lands on the LEFT and is ignored). This matches chi's -// middleware.RealIP semantics for a single trusted proxy hop. -// -// Direct connections (no XFF header) fall back to RemoteAddr. Directly -// reachable deployments must NOT expose the app to untrusted networks -// without a proxy in front, or attackers could forge the rightmost entry. -func clientIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if i := strings.LastIndex(xff, ","); i >= 0 { - return strings.TrimSpace(xff[i+1:]) - } - return strings.TrimSpace(xff) - } - if xr := r.Header.Get("X-Real-Ip"); xr != "" { - return strings.TrimSpace(xr) - } - host := r.RemoteAddr - if i := strings.LastIndex(host, ":"); i > 0 { - host = host[:i] - } - return host -} - var globalLimiter = newLimiter() // globalSettingsFn is set at startup; tests can point it at fixed settings. diff --git a/internal/api/ratelimit_xff_test.go b/internal/api/ratelimit_xff_test.go index 8674636..680d5d0 100644 --- a/internal/api/ratelimit_xff_test.go +++ b/internal/api/ratelimit_xff_test.go @@ -1,85 +1,57 @@ package api -// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry -// (appended by the trusted Traefik proxy), never the raw/leftmost header -// value a client can forge. A spoofed FIRST XFF entry must not bypass the -// limit or rotate buckets. - import ( - "bytes" + "fmt" "net/http/httptest" "testing" ) -func TestClientIPTakesRightmostXFF(t *testing.T) { - r := httptest.NewRequest("POST", "/", nil) - r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod +func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { + SetTrustedIPHeader("") + defer SetTrustedIPHeader("") + r := httptest.NewRequest("POST", "/api/pastes", nil) + r.RemoteAddr = "203.0.113.7:4432" r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9") - if got := clientIP(r); got != "203.0.113.9" { - t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got) - } -} - -func TestClientIPXRealIPFallback(t *testing.T) { - r := httptest.NewRequest("POST", "/", nil) - r.RemoteAddr = "10.42.0.7:51000" r.Header.Set("X-Real-Ip", "203.0.113.10") - if got := clientIP(r); got != "203.0.113.10" { - t.Fatalf("clientIP = %q, want 203.0.113.10", got) + if got := clientIP(r); got != "203.0.113.7" { + t.Fatalf("clientIP = %q, want peer 203.0.113.7", got) } } -func TestClientIPDirectFallback(t *testing.T) { - r := httptest.NewRequest("POST", "/", nil) - r.RemoteAddr = "198.51.100.5:51000" - if got := clientIP(r); got != "198.51.100.5" { - t.Fatalf("clientIP = %q, want 198.51.100.5", got) +func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) { + SetTrustedIPHeader("") + defer SetTrustedIPHeader("") + r := httptest.NewRequest("POST", "/api/pastes", nil) + r.RemoteAddr = "10.0.1.47:9999" + r.Header.Set("CF-Connecting-IP", "198.51.100.9") + if got := clientIP(r); got != "10.0.1.47" { + t.Fatalf("unconfigured: clientIP = %q, want peer 10.0.1.47", got) + } + SetTrustedIPHeader("CF-Connecting-IP") + if got := clientIP(r); got != "198.51.100.9" { + t.Fatalf("configured: clientIP = %q, want CF-Connecting-IP value", got) } } -// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake -// leftmost XFF entry stays limited on their real (rightmost) IP. -func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) { - srv := newTestServer(t) - h := srv.routes() - for i := 0; i < 5; i++ { - req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) - req.RemoteAddr = "10.42.0.7:51000" - // each request spoofs a DIFFERENT leftmost entry - req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 201 { - t.Fatalf("req %d: want 201, got %d", i, rr.Code) +// Issue #280: rotating X-Forwarded-For must NOT reset the bucket. Pentest +// repro was 8 creates with rotating XFF -> 6x201. +func TestRotatingXFFDoesNotResetBucket(t *testing.T) { + globalLimiter = newLimiter() + defer SetTrustedIPHeader("") + SetTrustedIPHeader("") + s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway + var allowed, limited int + for i := 0; i < 8; i++ { + r := httptest.NewRequest("POST", "/api/pastes", nil) + r.RemoteAddr = "198.51.100.1:5000" + r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i)) + if rateLimitCreate(r, s) { + allowed++ + } else { + limited++ } } - // 6th request, still the same real IP, new spoofed prefix: must 429 - req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) - req.RemoteAddr = "10.42.0.7:51000" - req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 429 { - t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code) - } -} - -func spoofN(i int) string { - return "1.2.3." + string(rune('0'+i)) -} - -// Distinct real IPs must still get distinct buckets (no over-limiting). -func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) { - srv := newTestServer(t) - h := srv.routes() - for _, ip := range []string{"203.0.113.20", "203.0.113.21"} { - req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) - req.RemoteAddr = "10.42.0.7:51000" - req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - if rr.Code != 201 { - t.Fatalf("ip %s: want 201, got %d", ip, rr.Code) - } + if float64(allowed) != s.RateLimitBurst || limited != 8-int(s.RateLimitBurst) { + t.Fatalf("rotating XFF: allowed=%d limited=%d, want allowed=%v (burst), limited=%d", allowed, limited, s.RateLimitBurst, 8-int(s.RateLimitBurst)) } } diff --git a/internal/api/routes_rename_test.go b/internal/api/routes_rename_test.go new file mode 100644 index 0000000..6a02ec0 --- /dev/null +++ b/internal/api/routes_rename_test.go @@ -0,0 +1,52 @@ +package api + +// #256: renamed page routes; old URLs redirect. +import ( + "palette/internal/store" + "palette/internal/web" + + "net/http" + "net/http/httptest" + "testing" +) + +func TestRenamedPageRoutes(t *testing.T) { + globalLimiter = newLimiter() // fresh rate-limit buckets + st, err := store.OpenStore(":memory:") + if err != nil { + t.Fatal(err) + } + ui, err := web.New() + if err != nil { + t.Fatal(err) + } + cfg := Config{MaxTextBytes: 5 * 1024 * 1024} + ss := NewTestSettingsStore(t, cfg) + globalSettingsFn = ss.get + t.Cleanup(func() { globalSettingsFn = nil }) + a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} + h := a.routes() + + // new routes render pages + for _, path := range []string{"/public", "/saved"} { + req := httptest.NewRequest("GET", path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s: %d, want 200", path, rec.Code) + } + } + + // old routes redirect + for _, tc := range [][2]string{{"/history", "/public"}, {"/mine", "/saved"}, {"/", "/public"}} { + req := httptest.NewRequest("GET", tc[0], nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusFound { + t.Fatalf("GET %s: %d, want redirect", tc[0], rec.Code) + } + if loc := rec.Header().Get("Location"); loc != tc[1] { + t.Fatalf("GET %s redirects to %s, want %s", tc[0], loc, tc[1]) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index aae5c96..b5d30d3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,6 +28,10 @@ type Config struct { DBPath string MaxTextBytes int64 MaxItemBytes int64 + // TrustedIPHeader optionally names a proxy-controlled client-IP header + // (e.g. CF-Connecting-IP behind Cloudflare) to key rate limits on. Empty + // (default) keys on the peer address only. See clientip.go (#280). + TrustedIPHeader string } type apiServer struct { @@ -39,6 +43,7 @@ type apiServer struct { } func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer { + SetTrustedIPHeader(cfg.TrustedIPHeader) // #280 return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey} } @@ -118,11 +123,14 @@ func (a *apiServer) routes() http.Handler { r.Get("/raw/{id}", a.handleRaw) // web pages - r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP) + r.Get("/", http.RedirectHandler("/public", http.StatusFound).ServeHTTP) r.Get("/new", a.ui.Handlers().HandleNewPage) - r.Get("/history", a.ui.Handlers().HandleHistoryPage) + r.Get("/public", a.ui.Handlers().HandleHistoryPage) + r.Get("/saved", a.ui.Handlers().HandleMinePage) r.Get("/settings", a.ui.Handlers().HandleSettingsPage) - r.Get("/mine", a.ui.Handlers().HandleMinePage) + // #256: old URLs redirect to the renamed pages + r.Get("/history", http.RedirectHandler("/public", http.StatusMovedPermanently).ServeHTTP) + r.Get("/mine", http.RedirectHandler("/saved", http.StatusMovedPermanently).ServeHTTP) r.Handle("/static/*", a.ui.StaticHandler()) r.Get("/unlock/{id}", a.handlePasteView) r.Post("/unlock/{id}", a.handlePasteView) @@ -493,15 +501,18 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", 404) return } - // #221: raw view of an image paste serves the image bytes themselves as - // an image, not the (empty) text content. - if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) { + // #221: raw view of a paste backed by an attachment serves the stored + // blob bytes with the sniffed mime, not the (empty) text content — for + // ALL attachment mimes (#281); /raw/{id} is the raw fetch for the file + // too. serveContentType still forces active-content types (html, svg, + // xml) to text/plain per the #34 rule below. + if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil { blobs := a.store.Blobs() if blobs != nil { if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil { defer blob.Close() a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95) - w.Header().Set("Content-Type", att.Mime) + w.Header().Set("Content-Type", serveContentType(att.Mime)) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size)) http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob) diff --git a/internal/store/attachment.go b/internal/store/attachment.go index a944cb3..70981ab 100644 --- a/internal/store/attachment.go +++ b/internal/store/attachment.go @@ -26,7 +26,10 @@ type Attachment struct { SizeHuman string `json:"-"` // template-only: human-readable size } -const MaxFilenameLen = 255 +// MaxFilenameLen caps stored attachment filenames (bytes) to bound DB +// rows and Content-Disposition echoes. 128 keeps names readable while +// stopping filename-bloat abuse; longer names truncate. +const MaxFilenameLen = 128 // ErrFileTooLarge is returned when an attachment exceeds the per-file cap. var ErrFileTooLarge = errors.New("file too large") diff --git a/internal/store/blob_test.go b/internal/store/blob_test.go index 70a20c8..1eba5a7 100644 --- a/internal/store/blob_test.go +++ b/internal/store/blob_test.go @@ -105,4 +105,9 @@ func TestSanitizeFilename(t *testing.T) { if got := SanitizeFilename(long); len(got) != MaxFilenameLen { t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen) } + // issue #248: a 250-char multipart filename must truncate to the cap + repro := strings.Repeat("b", 246) + ".txt" + if got := SanitizeFilename(repro); len(got) != MaxFilenameLen { + t.Errorf("repro name len = %d want %d", len(got), MaxFilenameLen) + } } diff --git a/internal/store/customslug.go b/internal/store/customslug.go index 65f0ae4..e39b378 100644 --- a/internal/store/customslug.go +++ b/internal/store/customslug.go @@ -13,7 +13,7 @@ var reservedSlugs = map[string]bool{ "api": true, "raw": true, "can": true, "cans": true, "public": true, "history": true, "static": true, "assets": true, "favicon.ico": true, "new": true, "login": true, "logout": true, "admin": true, "settings": true, - "mine": true, "unlock": true, "guess": true, "f": true, + "mine": true, "saved": true, "unlock": true, "guess": true, "f": true, } var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit") diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 389c78f..8c2b963 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -299,8 +299,14 @@ html[data-wrap] .float { overflow-x: hidden; } .code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } .code { font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh); - padding: 14px 0; display: flex; overflow-x: auto; + padding: 14px 0; display: flex; overflow-x: hidden; } +/* #261: horizontal scroll must live on the codebody, not the .code flex + container — a container-level scroll takes the gutter with it when the + user scrolls long lines. The gutter sits OUTSIDE the scroll container and + stays visible; the codebody shrinks to the remaining space and scrolls + (min-width: 0 lets it shrink below its content width inside the flex row). */ +.code .codebody { flex: 1 1 auto; min-width: 0; overflow-x: auto; } /* #167: the gutter must not drive the flex layout — its content width (row count × number width) shrinks the code column, which re-wraps lines, which grows the gutter: a feedback loop. Pin the gutter with a fixed @@ -310,11 +316,13 @@ html[data-wrap] .float { overflow-x: hidden; } .code .gutter { flex-shrink: 0; } /* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */ .code .gutter { padding-top: 0; padding-bottom: 0; } -.codebody { padding: 0 18px; white-space: pre; } +.codebody { padding: 0 18px; white-space: pre; overflow-x: auto; } /* #167: each logical line is its own block so offsetTop identifies its first visual row */ .codeline { display: block; } -/* #167 rev: gutter number spans must stack one per visual row (wrap on) */ -.code .gutter .gutline { display: block; } +/* #167: gutter number spans must stack one per visual row (wrap on). + #274: the /new editor gutter uses the same .gutline blocks when its own + wrap toggle is on, so scope the rule to any gutter, not just .code. */ +.code .gutter .gutline, .editor-wrap .gutter .gutline { display: block; } /* #194: codeline blocks are adjacent (no '\n' text between them), so an empty block (blank source line) needs its own line box to stay one row */ .codeline:empty::before { content: "\200B"; } @@ -580,6 +588,20 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } border-color: var(--ok); } +/* #260: static-width success feedback. Label and checkmark stack in one + grid cell, so the button is always as wide as the wider of the two and + never shifts on click. Feedback is a pure .ok class toggle. */ +.swapbtn { + display: inline-grid; +} +.swapbtn > * { + grid-area: 1 / 1; + justify-self: center; +} +.swapbtn .swap-check { visibility: hidden; } +.swapbtn.ok .swap-check { visibility: visible; } +.swapbtn.ok .swap-label { visibility: hidden; } + /* headings: unified treatment (mirrors .side-section h3) */ .settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 { letter-spacing: -0.01em; @@ -869,6 +891,9 @@ button[type="submit"]:focus-visible, .col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } .col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; } .col-f { width: 100px; } .col-g { width: 190px; } +/* #255: history's URL column had its own narrow width (col-f doubles as + /mine's ID column); give it a dedicated class. */ +.col-url { width: 150px; } /* #210: /mine rows render a delete button cell that had no declared column, so under table-layout:fixed it overlapped the ID column. */ .col-del { width: 64px; } @@ -884,3 +909,33 @@ button[type="submit"]:focus-visible, .created-banner { display: block; } /* #167: gutter rows for wrapped paste view — one row per visual code line */ .gutline { display: block; } + +/* #267: jump to top/bottom pills for long pastes and the editor. + Hidden unless JS (jump.js) detects content more than 2x the viewport. */ +.jumpnav { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 50; + display: flex; + flex-direction: column; + gap: 8px; +} +.jumpnav.hidden { display: none; } +.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); } + +/* #273: theme-aware scrollbars. Standard properties first (Firefox, and + Chromium >= 121 honors scrollbar-color), then ::-webkit rules for finer + Chromium styling. Colors come from CSS vars so they track the preset. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 5px; +} +::-webkit-scrollbar-thumb:hover { background: var(--muted-fg); } +::-webkit-scrollbar-corner { background: transparent; } diff --git a/internal/web/static/jump.js b/internal/web/static/jump.js new file mode 100644 index 0000000..cb1574b --- /dev/null +++ b/internal/web/static/jump.js @@ -0,0 +1,51 @@ +/* #267: jump to top / bottom controls for long content. + Paste view scrolls the window; the /new editor scrolls its textarea. + The active scroller is chosen via data-jump-scroll on the script tag. */ +(function () { + var nav = document.getElementById('jumpnav'); + if (!nav) return; + var scroller = window; + var sel = nav.dataset.jumpScroll; + if (sel) scroller = document.querySelector(sel); + + function el() { + return scroller === window ? document.scrollingElement : scroller; + } + function isLarge() { + var e = el(); + if (!e) return false; + var visible = scroller === window ? window.innerHeight : e.clientHeight; + return e.scrollHeight > visible * 2; + } + function refresh() { + nav.classList.toggle('hidden', !isLarge()); + } + function jump(toTop) { + var e = el(); + if (!e) return; + if (scroller === window) { + window.scrollTo({ top: toTop ? 0 : e.scrollHeight }); + } else { + e.scrollTop = toTop ? 0 : e.scrollHeight; + } + } + nav.addEventListener('click', function (ev) { + var b = ev.target.closest('[data-jump]'); + if (!b) return; + ev.preventDefault(); + jump(b.dataset.jump === 'top'); + }); + window.addEventListener('resize', refresh); + if (scroller !== window && scroller) scroller.addEventListener('input', refresh); + refresh(); + /* #282: the first evaluation can run before the layout settles (media + queries, web fonts, async highlighting) and under-measure the content, + leaving the nav hidden on long pages. Re-check once a real layout exists + and after load; the ResizeObserver also catches late content growth. */ + requestAnimationFrame(function () { requestAnimationFrame(refresh); }); + window.addEventListener('load', refresh); + window.setTimeout(refresh, 300); + if (window.ResizeObserver && scroller === window && document.body) { + new ResizeObserver(refresh).observe(document.body); + } +})(); diff --git a/internal/web/static/new.js b/internal/web/static/new.js index 3e4cf2c..3b4453f 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -2,13 +2,73 @@ const $ = id => document.getElementById(id); const content = $('content'), gutter = $('gutter'); +// #274: with wrap on, a logical line occupies several VISUAL rows in the +// textarea, so one number per logical line drifts off its text (same bug the +// paste view fixed in #167). A textarea can't be split into spans, so the +// wrapped row count per logical line is measured with a hidden mirror div +// that shares the editor's font, line metrics and wrapping rules, and the +// gutter renders one .gutline block per visual row with the number on the +// FIRST row of its logical line (fillers elsewhere). +let mirror = null; +function measureRows(lines) { + if (!mirror) { + mirror = document.createElement('div'); + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.top = '0'; + mirror.style.left = '-9999px'; + document.body.appendChild(mirror); + } + const cs = getComputedStyle(content); + mirror.style.font = cs.font; + mirror.style.lineHeight = cs.lineHeight; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.overflowWrap = 'anywhere'; + mirror.style.wordBreak = 'break-all'; + mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px'; + const lh = parseFloat(cs.lineHeight) || 1; + const starts = []; + let total = 0; + const n = Math.max(lines.length, 1); + for (let i = 0; i < n; i++) { + // A trailing newline yields an empty last line: it still occupies one row. + mirror.textContent = lines[i] + '\n'; + let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh)); + starts.push(total); + total += rows; + } + return { starts, total }; +} + function updateGutter() { - const lines = content.value.split('\n').length; - let s = ''; - for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n'; - gutter.textContent = s; + const lines = content.value.split('\n'); + const n = Math.max(lines.length, 1); + if (!document.documentElement.hasAttribute('data-wrap')) { + let s = ''; + for (let i = 1; i <= n; i++) s += i + '\n'; + gutter.textContent = s.slice(0, -1); + return; + } + const { starts, total } = measureRows(lines); + gutter.textContent = ''; + const frag = document.createDocumentFragment(); + const spans = []; + for (let r = 0; r < total; r++) { + const c = document.createElement('span'); + c.className = 'gutline'; + c.textContent = '\u00a0'; + spans.push(c); + frag.appendChild(c); + } + gutter.appendChild(frag); + for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1); } content.addEventListener('input', updateGutter); +// #274: the wrap toggle and width changes re-wrap the textarea; re-measure. +new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] }); +window.addEventListener('resize', updateGutter); +// #259: the editor scrolls itself; keep the gutter's numbers in step with it. +content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; }); updateGutter(); function toast(msg, kind) { @@ -190,15 +250,16 @@ async function create() { // button, password auto-unlock, then redirect to the paste. function finishCreate(data) { const url = location.origin + '/' + (data.custom_slug || data.id); - showResult('' + url + ' ', 'ok'); + // #260 attempt 2: .swapbtn markup — label and checkmark share one grid + // cell, so the button width is static and feedback is a class toggle. + showResult('' + url + ' ', 'ok'); $('result').dataset.token = data.deletion_token || ''; const copyBtn = document.getElementById('result-copy'); copyBtn.addEventListener('click', () => { try { navigator.clipboard.writeText(url); copyBtn.classList.add('ok'); // in-place success feedback (#53) - copyBtn.textContent = 'Success!'; - setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000); + setTimeout(() => copyBtn.classList.remove('ok'), 2000); } catch(e) { toast('Copy failed', 'error'); } }); // token carried via sessionStorage, never in the URL (#143) diff --git a/internal/web/static/paste-lines.js b/internal/web/static/paste-lines.js index e385e9d..fc6aabb 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -44,6 +44,23 @@ // measured, not derived from span counts or heights. function renumber() { var lines = body.querySelectorAll('.codeline'); + // #257: size the gutter column to the widest line number so numbers in + // the 100s+ fit their own column instead of bleeding into the code text. + // The gutter is box-sizing: border-box, so the column width must be the + // digits PLUS the 10px left + 10px right padding; at the CSS default 3ch + // the padding alone leaves only ~19px of content, and any 2+ digit + // number overflows into the code. Numbers are right-aligned, and the + // width below fits the widest number exactly. Set via CSSOM (CSP + // forbids inline style attributes). Only touch the width when it + // changes: the resize observer below re-runs renumber() when the gutter + // width reflows the code column, and rewriting the same value would + // ping-pong the fixed point forever. + var digits = String(lines.length || 1).length; + var w = 'calc(' + digits + 'ch + 20px)'; + if (gutter.style.width !== w) { + gutter.style.minWidth = w; + gutter.style.width = w; + } if (!wrapOn() || !lines.length) { // wrap OFF: one number per logical line (pre-existing behavior, // including the gutter scrolling with horizontal scroll). diff --git a/internal/web/static/paste.js b/internal/web/static/paste.js index 0e88715..cc4fb4f 100644 --- a/internal/web/static/paste.js +++ b/internal/web/static/paste.js @@ -18,11 +18,12 @@ function toggleStats() { } function copyFeedback(btn) { if (!btn) return; - if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link) + // #260 attempt 2: .swapbtn stacks the label and checkmark in the same grid + // cell, so the button width is always the wider of the two and never moves. + // Feedback is a pure class toggle; no width pinning, no textContent swap. btn.classList.add('ok'); - btn.textContent = 'Success!'; clearTimeout(btn._okh); - btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = btn.dataset.label; }, 2000); + btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000); } function copyContent(btn) { navigator.clipboard.writeText(document.getElementById('raw-content').value) @@ -42,7 +43,7 @@ function redeem() { try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {} if (!tok) { alert('deletion token not available in this browser'); return; } fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}}) - .then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); }); + .then(r => { if (r.ok) location.href = '/public'; else alert('delete failed'); }); } // wiring (moved from inline handlers for CSP #139) diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index 88359c7..2896a62 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -8,7 +8,7 @@
- + diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index b38e262..61eeca0 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -8,11 +8,11 @@ {{define "topbar"}}
- +
diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index ef90b5f..e21941f 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -33,6 +33,10 @@
+
@@ -90,4 +94,5 @@
+ {{template "foot" .}} diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index 74456cd..25a2d2e 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -8,8 +8,8 @@
Raw - Link - Copy + Link + Copy {{if .DeletionToken}}Delete{{end}} @@ -50,13 +50,18 @@ {{end}} - {{if not .AttachmentImage}} + {{if not .Attachment}}
{{.Gutter}}
{{.ContentHTML}}
{{end}} + + {{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index cbd71a7..049817d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -433,9 +433,9 @@ func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) { h.renderPage(w, "new.html", map[string]any{"Page": "new"}) } -// HandleHistoryPage serves /history. +// HandleHistoryPage serves /public. func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) { - h.renderPage(w, "history.html", map[string]any{"Page": "history"}) + h.renderPage(w, "history.html", map[string]any{"Page": "public"}) } // HandleSettingsPage serves /settings. @@ -452,9 +452,9 @@ func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes}) } -// HandleMinePage serves /mine. +// HandleMinePage serves /saved. func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) { - h.renderPage(w, "mine.html", map[string]any{"Page": "mine"}) + h.renderPage(w, "mine.html", map[string]any{"Page": "saved"}) } // HandleAdminPage serves /admin.
Paste Type