13 Commits
Author SHA1 Message Date
fen a8a953e4bf Merge pull request 'Release v0.5.1: dev -> main' (#302) from dev into main
CI / test (push) Successful in 26s
CI / docker (push) Successful in 38s
2026-09-18 18:50:51 +00:00
fen feb36fc501 Merge pull request '#296 fix attempt 3: shrink URL column, plain-text URL values' (#301) from fix-296-r3 into dev
CI / test (push) Successful in 26s
CI / docker (push) Successful in 41s
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-18 16:23:38 +00:00
fen ce14c6b713 #296 r3: shrink URL column, render URL values as plain text
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
Owner override postdating PR #300 QA pass:
- .col-url 340 -> 230px; reclaim slack from history-only columns (Views 96->90,
  Created 190->150, ID 190->120) so the table fits the .float pane with no
  horizontal scroll at 1400x900 and the ID column is fully visible.
- URL anchors drop the shared .slug pill (background/padding/radius/mono font)
  and render plain, styled via td.dim exactly like the none case; links stay
  anchors with accent hover. Ellipsis kept for genuinely long slugs.

Verified by CDP probe on a local build at 1400x900 and 375x812 on /public and
/mine (/saved): column order, table fits pane (scrollWidth == clientWidth),
20-char slug fully visible, min Created-to-URL content gap 77px, sort arrows
intact 6px right of labels, no CSP/console violations. go build/test pass.
2026-09-18 11:15:48 -05:00
fen d87b93819b Merge pull request 'URL column: restore position 6 and widen (fix attempt 2)' (#300) from fix-296-r2 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 32s
2026-09-18 14:05:02 +00:00
fen 73c06b864a #296 r2: restore URL column to position 6 and widen to 340px
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
Reverts PR #297's column reorder (owner override): URL returns to position
6 in both list tables (Paste, Type, Size, Views, Created, URL, ID; /mine
without Views), data-sort attrs and td cells move back together. Keeps
#297's non-position improvements: /mine URL uses the shared .col-url class
instead of reusing col-e, and mine's ID column returns to .col-f (100px).

Widens .col-url 150px -> 340px so a ~20-char slug displays fully at
1400x900; genuinely long custom slugs keep text ellipsis. No mobile media
query needed: <=640px already scrolls the table horizontally (min-width
720px), verified at 375x812.

Verified via CDP probe at 1400x900 and 375x812 on /history and /mine:
column order, colgroup/header alignment, sort arrow right of label, no
other column clipped, no CSP changes (no inline styles added).
2026-09-18 09:01:03 -05:00
fen a3abf7c73e Merge pull request '#296: move URL column to position 2 (after Paste) in list tables' (#297) from fix-296 into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 32s
2026-09-18 03:56:28 +00:00
fen 6e85a7686d #296: move URL column to position 2 (right after Paste) in history and mine tables
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-17 22:54:19 -05:00
fen 2046e45601 Merge pull request '#294: switching to midnight theme forces dark mode' (#295) from fix-294 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 34s
2026-09-18 03:14:20 +00:00
fen 105ffc9f17 #294: resolve midnight theme variant via lightPreset/darkPreset (midnight is dark-first)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-17 22:12:25 -05:00
fen 4fa60ea7b9 Merge pull request '#292: highlight paste name uniformly on row hover in list tables' (#293) from fix-292 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 35s
2026-09-18 02:58:13 +00:00
fen e87bdb221e #292: highlight paste name uniformly on row hover in list tables
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-17 21:45:44 -05:00
fen aa72b4b38b Merge pull request '#280: remove PALETTE_TRUSTED_IP_HEADER, rate limits key on peer address only' (#291) from fix-280-r2 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 42s
2026-09-18 01:53:23 +00:00
fen aac1725c25 #280: remove PALETTE_TRUSTED_IP_HEADER, key rate limits on peer address only
CI / test (pull_request) Successful in 31s
CI / docker (pull_request) Skipped
Owner follow-up to the #280 fix (PR #284): the trusted-header env var is
gone. clientIP() now uses the peer address exclusively and ignores all
client-supplied IP headers; the env var row is removed from the README.
2026-09-17 20:48:52 -05:00
9 changed files with 27 additions and 62 deletions
-1
View File
@@ -70,7 +70,6 @@ go build -o palette ./cmd/palette
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) | | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/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_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_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 An `/admin` page exists for runtime settings, protected by a key set at
install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See
+3 -33
View File
@@ -7,48 +7,18 @@
// per request and the limit was unenforceable (pentest H1: 6x201 across 8 // per request and the limit was unenforceable (pentest H1: 6x201 across 8
// rotating-XFF creates). // rotating-XFF creates).
// //
// Default: key on the actual peer address (RemoteAddr) only. Behind any // The bucket key is the actual peer address (RemoteAddr) only. Behind any
// reverse proxy this is the proxy's address, so all clients share one bucket // reverse proxy this is the proxy's address, so all clients share one bucket
// per endpoint — coarse, but safe. // per endpoint — coarse, but safe. Client-supplied IP headers
// // (X-Forwarded-For, X-Real-Ip) are never trusted.
// 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 package api
import ( import (
"net" "net"
"net/http" "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 { func clientIP(r *http.Request) string {
if name := getTrustedIPHeader(); name != "" {
if v := r.Header.Get(name); v != "" {
return v
}
}
host := r.RemoteAddr host := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
host = h host = h
+6 -13
View File
@@ -7,8 +7,6 @@ import (
) )
func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
SetTrustedIPHeader("")
defer SetTrustedIPHeader("")
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "203.0.113.7:4432" 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") r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
@@ -18,18 +16,15 @@ func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
} }
} }
func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) { // A proxy-controlled header is not honored even when set: #280 revision
SetTrustedIPHeader("") // removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the
defer SetTrustedIPHeader("") // bucket key is the peer address only.
func TestClientIPNeverTrustsHeaders(t *testing.T) {
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "10.0.1.47:9999" r.RemoteAddr = "10.0.1.47:9999"
r.Header.Set("CF-Connecting-IP", "198.51.100.9") r.Header.Set("CF-Connecting-IP", "198.51.100.9")
if got := clientIP(r); got != "10.0.1.47" { if got := clientIP(r); got != "10.0.1.47" {
t.Fatalf("unconfigured: clientIP = %q, want peer 10.0.1.47", got) t.Fatalf("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)
} }
} }
@@ -37,9 +32,7 @@ func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) {
// repro was 8 creates with rotating XFF -> 6x201. // repro was 8 creates with rotating XFF -> 6x201.
func TestRotatingXFFDoesNotResetBucket(t *testing.T) { func TestRotatingXFFDoesNotResetBucket(t *testing.T) {
globalLimiter = newLimiter() globalLimiter = newLimiter()
defer SetTrustedIPHeader("") s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway
SetTrustedIPHeader("")
s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway
var allowed, limited int var allowed, limited int
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
-5
View File
@@ -28,10 +28,6 @@ type Config struct {
DBPath string DBPath string
MaxTextBytes int64 MaxTextBytes int64
MaxItemBytes 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 { type apiServer struct {
@@ -43,7 +39,6 @@ type apiServer struct {
} }
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer { 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} return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
} }
+9 -4
View File
@@ -354,10 +354,15 @@ tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; } tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); } tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); } tr.row:hover td a.slug { color: var(--accent); }
/* #292: highlight the paste NAME uniformly on row hover, titled or not */
tr.row:hover td .paste-name { color: var(--accent); }
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; } td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.url-link { font-family: inherit; font-size: inherit; color: inherit; text-decoration: none; }
td a.url-link:hover { color: var(--accent); }
td a.slug:hover { color: var(--accent); } td a.slug:hover { color: var(--accent); }
/* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */ /* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */
td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); } td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); }
td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit; font-size: inherit; }
.badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; } .badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; }
.badge.lock { color: var(--accent); border-color: var(--accent); } .badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; } .dim { color: var(--muted-fg); white-space: nowrap; }
@@ -528,7 +533,7 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
/* paste name under slug pill in Paste column (#43) */ /* paste name under slug pill in Paste column (#43) */
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paste-sub.dim { color: var(--muted); } .paste-sub.dim { color: var(--muted); }
td a.slug.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; } td a.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; } td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); } td .id-link:hover { color: var(--accent); }
@@ -889,11 +894,11 @@ button[type="submit"]:focus-visible,
.hidden { display: none; } .hidden { display: none; }
.can-page { max-width: 900px; width: 100%; } .can-page { max-width: 900px; width: 100%; }
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } .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-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
.col-f { width: 100px; } .col-g { width: 190px; } .col-f { width: 100px; } .col-g { width: 120px; }
/* #255: history's URL column had its own narrow width (col-f doubles as /* #255: history's URL column had its own narrow width (col-f doubles as
/mine's ID column); give it a dedicated class. */ /mine's ID column); give it a dedicated class. */
.col-url { width: 150px; } .col-url { width: 230px; }
/* #210: /mine rows render a delete button cell that had no declared column, /* #210: /mine rows render a delete button cell that had no declared column,
so under table-layout:fixed it overlapped the ID column. */ so under table-layout:fixed it overlapped the ID column. */
.col-del { width: 64px; } .col-del { width: 64px; }
+2 -2
View File
@@ -6,12 +6,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` + `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`, `<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.', emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.', emptyAll: 'No pastes yet. Create the first one.',
+2 -2
View File
@@ -18,12 +18,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` + `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` + `<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`, `<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.', emptyFiltered: 'No pastes from this browser match your search.',
+4 -1
View File
@@ -73,7 +73,10 @@
}); });
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var dark = state().dark; var dark = state().dark;
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id; // #294: use the generic variant resolvers - midnight is dark-first
// (dark preset = 'midnight', light = 'midnight-light'), so a raw
// t.id + '-dark' build landed on nonexistent/forced-dark presets.
document.documentElement.dataset.preset = dark ? darkPreset(t.id) : lightPreset(t.id);
try { localStorage.setItem('palette-theme', t.id); } catch (e) {} try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true'); btn.setAttribute('aria-pressed', 'true');
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div> <div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <table>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-e"><col class="col-f"><col class="col-del"></colgroup> <colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-url"><col class="col-f"><col class="col-del"></colgroup>
<thead><tr> <thead><tr>
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th> <th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
<th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th> <th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th>