1 Commits
Author SHA1 Message Date
fen 522e016a29 Merge pull request 'Release v0.4.0: dev -> main' (#251) from dev into main
CI / docker (push) Successful in 36s
CI / test (push) Successful in 19s
2026-09-17 15:35:20 +00:00
17 changed files with 34 additions and 282 deletions
+1 -1
View File
@@ -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", "/public", nil)
req := httptest.NewRequest("GET", "/history", nil)
rec := httptest.NewRecorder()
a.routes().ServeHTTP(rec, req)
return rec
+2 -2
View File
@@ -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, "/public")
alice := viewerCookieFor(t, h, "/history")
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, "/public")
bob := viewerCookieFor(t, h, "/history")
rec = doReq(t, h, "GET", "/api/mine", bob, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
-52
View File
@@ -1,52 +0,0 @@
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])
}
}
}
+3 -6
View File
@@ -118,14 +118,11 @@ func (a *apiServer) routes() http.Handler {
r.Get("/raw/{id}", a.handleRaw)
// web pages
r.Get("/", http.RedirectHandler("/public", http.StatusFound).ServeHTTP)
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
r.Get("/new", a.ui.Handlers().HandleNewPage)
r.Get("/public", a.ui.Handlers().HandleHistoryPage)
r.Get("/saved", a.ui.Handlers().HandleMinePage)
r.Get("/history", a.ui.Handlers().HandleHistoryPage)
r.Get("/settings", a.ui.Handlers().HandleSettingsPage)
// #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.Get("/mine", a.ui.Handlers().HandleMinePage)
r.Handle("/static/*", a.ui.StaticHandler())
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
+1 -4
View File
@@ -26,10 +26,7 @@ type Attachment struct {
SizeHuman string `json:"-"` // template-only: human-readable size
}
// 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
const MaxFilenameLen = 255
// ErrFileTooLarge is returned when an attachment exceeds the per-file cap.
var ErrFileTooLarge = errors.New("file too large")
-5
View File
@@ -105,9 +105,4 @@ 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)
}
}
+1 -1
View File
@@ -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, "saved": true, "unlock": true, "guess": true, "f": true,
"mine": 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")
+4 -59
View File
@@ -299,14 +299,8 @@ 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: hidden;
padding: 14px 0; display: flex; overflow-x: auto;
}
/* #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
@@ -316,13 +310,11 @@ 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; overflow-x: auto; }
.codebody { padding: 0 18px; white-space: pre; }
/* #167: each logical line is its own block so offsetTop identifies its first visual row */
.codeline { 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; }
/* #167 rev: gutter number spans must stack one per visual row (wrap on) */
.code .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"; }
@@ -588,20 +580,6 @@ 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;
@@ -891,9 +869,6 @@ 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; }
@@ -909,33 +884,3 @@ 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; }
-41
View File
@@ -1,41 +0,0 @@
/* #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();
})();
+6 -67
View File
@@ -2,73 +2,13 @@
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');
const n = Math.max(lines.length, 1);
if (!document.documentElement.hasAttribute('data-wrap')) {
const lines = content.value.split('\n').length;
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);
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
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) {
@@ -250,16 +190,15 @@ async function create() {
// button, password auto-unlock, then redirect to the paste.
function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id);
// #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('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon swapbtn" id="result-copy" title="Copy URL" type="button"><span class="swap-label">Copy</span><span class="swap-check">✓</span></button>', 'ok');
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', '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)
setTimeout(() => copyBtn.classList.remove('ok'), 2000);
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// token carried via sessionStorage, never in the URL (#143)
-17
View File
@@ -44,23 +44,6 @@
// 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).
+4 -5
View File
@@ -18,12 +18,11 @@ function toggleStats() {
}
function copyFeedback(btn) {
if (!btn) return;
// #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.
if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link)
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = btn.dataset.label; }, 2000);
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value)
@@ -43,7 +42,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 = '/public'; else alert('delete failed'); });
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
// wiring (moved from inline handlers for CSP #139)
+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="float">
<table>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d"><col class="col-e"><col class="col-url"><col class="col-g"></colgroup>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d"><col class="col-e"><col class="col-f"><col class="col-g"></colgroup>
<thead><tr>
<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>
+3 -3
View File
@@ -8,11 +8,11 @@
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/public">Palette <em>/ {{ version }}</em></a>
<a class="logo" href="/history">Palette <em>/ {{ version }}</em></a>
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/public" {{if eq .Page "public"}}class="on"{{end}}>Public</a>
<a href="/saved" {{if eq .Page "saved"}}class="on"{{end}}>Saved</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
</nav>
<div class="spacer"></div>
-5
View File
@@ -33,10 +33,6 @@
<div class="spacer spacer-flex"></div>
<button class="btn" id="create">Create</button>
</div>
<div class="jumpnav hidden" id="jumpnav" data-jump-scroll="#content">
<button type="button" class="btn jump-btn" data-jump="top">Top</button>
<button type="button" class="btn jump-btn" data-jump="bottom">Bottom</button>
</div>
</div>
<div class="pane-r">
@@ -94,5 +90,4 @@
</div>
</div>
<script src="/static/new.js" defer></script>
<script src="/static/jump.js" defer></script>
{{template "foot" .}}
+3 -8
View File
@@ -8,8 +8,8 @@
<div class="spacer"></div>
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">Wrap</button>
<a class="iconbtn" href="/raw/{{.ID}}">Raw</a>
<a class="iconbtn swapbtn" href="#" id="copy-link-btn"><span class="swap-label">Link</span><span class="swap-check"></span></a>
<a class="iconbtn swapbtn" href="#" id="copy-btn"><span class="swap-label">Copy</span><span class="swap-check"></span></a>
<a class="iconbtn" href="#" id="copy-link-btn">Link</a>
<a class="iconbtn" href="#" id="copy-btn">Copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">Delete</a>{{end}}
</div>
</div>
@@ -50,18 +50,13 @@
</div>
</div>
{{end}}
{{if not .Attachment}}
{{if not .AttachmentImage}}
<div class="float">
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
</div>
{{end}}
<div class="jumpnav hidden" id="jumpnav">
<button type="button" class="btn jump-btn" data-jump="top">Top</button>
<button type="button" class="btn jump-btn" data-jump="bottom">Bottom</button>
</div>
</div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
<script src="/static/paste-lines.js" defer></script>
<script src="/static/jump.js" defer></script>
{{template "foot" .}}
+4 -4
View File
@@ -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 /public.
// HandleHistoryPage serves /history.
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "history.html", map[string]any{"Page": "public"})
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
}
// 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 /saved.
// HandleMinePage serves /mine.
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "mine.html", map[string]any{"Page": "saved"})
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
}
// HandleAdminPage serves /admin.