Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1872dac8b4 | ||
|
|
4050f1362e | ||
|
|
cdf9136866 | ||
|
|
4abc8ec202 | ||
|
|
ca54653a11 | ||
|
|
c91d0e53ca | ||
|
|
ee0cd7dcbd | ||
|
|
a4e1abfaae | ||
|
|
ca785f5648 | ||
|
|
91757752b8 | ||
|
|
bb2c5e200c | ||
|
|
8474b8eb02 | ||
|
|
cf6e52bcc5 | ||
|
|
3a8f528693 | ||
|
|
ed22579bed |
@@ -29,7 +29,7 @@ func newTestServer138(t *testing.T) *httptest.ResponseRecorder {
|
|||||||
globalSettingsFn = ss.get
|
globalSettingsFn = ss.get
|
||||||
t.Cleanup(func() { globalSettingsFn = nil })
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
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()
|
rec := httptest.NewRecorder()
|
||||||
a.routes().ServeHTTP(rec, req)
|
a.routes().ServeHTTP(rec, req)
|
||||||
return rec
|
return rec
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestMineCreateListDelete(t *testing.T) {
|
|||||||
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
h := a.routes()
|
h := a.routes()
|
||||||
|
|
||||||
alice := viewerCookieFor(t, h, "/history")
|
alice := viewerCookieFor(t, h, "/public")
|
||||||
if alice == "" {
|
if alice == "" {
|
||||||
t.Fatal("no viewer cookie issued")
|
t.Fatal("no viewer cookie issued")
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestMineCreateListDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// a different browser's cookie does NOT see it
|
// 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, "")
|
rec = doReq(t, h, "GET", "/api/mine", bob, "")
|
||||||
json.Unmarshal(rec.Body.Bytes(), &list)
|
json.Unmarshal(rec.Body.Bytes(), &list)
|
||||||
if list.Total != 0 {
|
if list.Total != 0 {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,11 +118,14 @@ func (a *apiServer) routes() http.Handler {
|
|||||||
r.Get("/raw/{id}", a.handleRaw)
|
r.Get("/raw/{id}", a.handleRaw)
|
||||||
|
|
||||||
// web pages
|
// 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("/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("/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.Handle("/static/*", a.ui.StaticHandler())
|
||||||
r.Get("/unlock/{id}", a.handlePasteView)
|
r.Get("/unlock/{id}", a.handlePasteView)
|
||||||
r.Post("/unlock/{id}", a.handlePasteView)
|
r.Post("/unlock/{id}", a.handlePasteView)
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ type Attachment struct {
|
|||||||
SizeHuman string `json:"-"` // template-only: human-readable size
|
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.
|
// ErrFileTooLarge is returned when an attachment exceeds the per-file cap.
|
||||||
var ErrFileTooLarge = errors.New("file too large")
|
var ErrFileTooLarge = errors.New("file too large")
|
||||||
|
|||||||
@@ -105,4 +105,9 @@ func TestSanitizeFilename(t *testing.T) {
|
|||||||
if got := SanitizeFilename(long); len(got) != MaxFilenameLen {
|
if got := SanitizeFilename(long); len(got) != MaxFilenameLen {
|
||||||
t.Errorf("long name len = %d want %d", 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var reservedSlugs = map[string]bool{
|
|||||||
"api": true, "raw": true, "can": true, "cans": true, "public": true,
|
"api": true, "raw": true, "can": true, "cans": true, "public": true,
|
||||||
"history": true, "static": true, "assets": true, "favicon.ico": true,
|
"history": true, "static": true, "assets": true, "favicon.ico": true,
|
||||||
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
"new": true, "login": true, "logout": true, "admin": true, "settings": 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")
|
var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ function updateGutter() {
|
|||||||
gutter.textContent = s;
|
gutter.textContent = s;
|
||||||
}
|
}
|
||||||
content.addEventListener('input', updateGutter);
|
content.addEventListener('input', updateGutter);
|
||||||
|
// #259: the editor scrolls itself; keep the gutter's numbers in step with it.
|
||||||
|
content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; });
|
||||||
updateGutter();
|
updateGutter();
|
||||||
|
|
||||||
function toast(msg, kind) {
|
function toast(msg, kind) {
|
||||||
@@ -197,7 +199,7 @@ function finishCreate(data) {
|
|||||||
try {
|
try {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
copyBtn.classList.add('ok'); // in-place success feedback (#53)
|
copyBtn.classList.add('ok'); // in-place success feedback (#53)
|
||||||
copyBtn.textContent = 'Success!';
|
copyBtn.textContent = '✓';
|
||||||
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
|
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
|
||||||
} catch(e) { toast('Copy failed', 'error'); }
|
} catch(e) { toast('Copy failed', 'error'); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,23 @@
|
|||||||
// measured, not derived from span counts or heights.
|
// measured, not derived from span counts or heights.
|
||||||
function renumber() {
|
function renumber() {
|
||||||
var lines = body.querySelectorAll('.codeline');
|
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) {
|
if (!wrapOn() || !lines.length) {
|
||||||
// wrap OFF: one number per logical line (pre-existing behavior,
|
// wrap OFF: one number per logical line (pre-existing behavior,
|
||||||
// including the gutter scrolling with horizontal scroll).
|
// including the gutter scrolling with horizontal scroll).
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function copyFeedback(btn) {
|
|||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link)
|
if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link)
|
||||||
btn.classList.add('ok');
|
btn.classList.add('ok');
|
||||||
btn.textContent = 'Success!';
|
btn.textContent = '✓';
|
||||||
clearTimeout(btn._okh);
|
clearTimeout(btn._okh);
|
||||||
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = btn.dataset.label; }, 2000);
|
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = btn.dataset.label; }, 2000);
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ function redeem() {
|
|||||||
try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {}
|
try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {}
|
||||||
if (!tok) { alert('deletion token not available in this browser'); return; }
|
if (!tok) { alert('deletion token not available in this browser'); return; }
|
||||||
fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}})
|
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)
|
// wiring (moved from inline handlers for CSP #139)
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
|
|
||||||
{{define "topbar"}}
|
{{define "topbar"}}
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<a class="logo" href="/history">Palette <em>/ {{ version }}</em></a>
|
<a class="logo" href="/public">Palette <em>/ {{ version }}</em></a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
|
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
|
||||||
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
|
<a href="/public" {{if eq .Page "public"}}class="on"{{end}}>Public</a>
|
||||||
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
|
<a href="/saved" {{if eq .Page "saved"}}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>
|
<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>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if not .AttachmentImage}}
|
{{if not .Attachment}}
|
||||||
<div class="float">
|
<div class="float">
|
||||||
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
|
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-4
@@ -433,9 +433,9 @@ func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
|
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) {
|
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.
|
// 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})
|
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) {
|
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.
|
// HandleAdminPage serves /admin.
|
||||||
|
|||||||
Reference in New Issue
Block a user