Compare commits
22
Commits
d4da322031
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a953e4bf | ||
|
|
feb36fc501 | ||
|
|
ce14c6b713 | ||
|
|
d87b93819b | ||
|
|
73c06b864a | ||
|
|
a3abf7c73e | ||
|
|
6e85a7686d | ||
|
|
2046e45601 | ||
|
|
105ffc9f17 | ||
|
|
4fa60ea7b9 | ||
|
|
e87bdb221e | ||
|
|
aa72b4b38b | ||
|
|
cb1b16ae41 | ||
|
|
aac1725c25 | ||
|
|
880f3cd958 | ||
|
|
1149989d07 | ||
|
|
ed435c5e13 | ||
|
|
c190dd9ea6 | ||
|
|
8901a3c82c | ||
|
|
3460d54fce | ||
|
|
bc52f0a608 | ||
|
|
84d19556fc |
@@ -31,7 +31,6 @@ a web UI for sharing text and small files.
|
|||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
|
|
||||||
Mobile previews (375x812): [editor](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-editor-new.png), [paste view (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [paste view (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-pastel-peach-light.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png).
|
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
@@ -71,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
|
||||||
|
|||||||
@@ -91,17 +91,6 @@ func (l *limitReader) Read(p []byte) (int, error) {
|
|||||||
return n, err
|
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
|
// handleCreatePasteMultipart implements POST /api/pastes with
|
||||||
// multipart/form-data (#38). Fields mirror the JSON create path; a 'file'
|
// 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
|
// part makes the paste a file paste (1 file = 1 paste: if text content is
|
||||||
|
|||||||
@@ -306,3 +306,64 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) {
|
|||||||
t.Fatalf("paste should require password, got %d", rec2.Code)
|
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("<html><body><script>alert(1)</script></body></html>")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,15 +496,18 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "not found", 404)
|
http.Error(w, "not found", 404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// #221: raw view of an image paste serves the image bytes themselves as
|
// #221: raw view of a paste backed by an attachment serves the stored
|
||||||
// an image, not the (empty) text content.
|
// blob bytes with the sniffed mime, not the (empty) text content — for
|
||||||
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) {
|
// 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()
|
blobs := a.store.Blobs()
|
||||||
if blobs != nil {
|
if blobs != nil {
|
||||||
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
|
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
|
||||||
defer blob.Close()
|
defer blob.Close()
|
||||||
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
|
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("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
|
||||||
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
|
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -38,4 +38,14 @@
|
|||||||
window.addEventListener('resize', refresh);
|
window.addEventListener('resize', refresh);
|
||||||
if (scroller !== window && scroller) scroller.addEventListener('input', refresh);
|
if (scroller !== window && scroller) scroller.addEventListener('input', refresh);
|
||||||
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);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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">×</button></td></tr>`,
|
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">×</button></td></tr>`,
|
||||||
emptyFiltered: 'No pastes from this browser match your search.',
|
emptyFiltered: 'No pastes from this browser match your search.',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user