Merge origin/dev into fix-255 for QA (rebase onto current dev)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -13,7 +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, "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")
|
var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
|
||||||
|
|||||||
@@ -887,3 +887,17 @@ button[type="submit"]:focus-visible,
|
|||||||
.created-banner { display: block; }
|
.created-banner { display: block; }
|
||||||
/* #167: gutter rows for wrapped paste view — one row per visual code line */
|
/* #167: gutter rows for wrapped paste view — one row per visual code line */
|
||||||
.gutline { display: block; }
|
.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); }
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/* #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();
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
<div class="spacer spacer-flex"></div>
|
<div class="spacer spacer-flex"></div>
|
||||||
<button class="btn" id="create">Create</button>
|
<button class="btn" id="create">Create</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="pane-r">
|
<div class="pane-r">
|
||||||
@@ -90,4 +94,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/new.js" defer></script>
|
<script src="/static/new.js" defer></script>
|
||||||
|
<script src="/static/jump.js" defer></script>
|
||||||
{{template "foot" .}}
|
{{template "foot" .}}
|
||||||
|
|||||||
@@ -55,8 +55,13 @@
|
|||||||
<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>
|
||||||
{{end}}
|
{{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>
|
</div>
|
||||||
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
||||||
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
|
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
|
||||||
<script src="/static/paste-lines.js" defer></script>
|
<script src="/static/paste-lines.js" defer></script>
|
||||||
|
<script src="/static/jump.js" defer></script>
|
||||||
{{template "foot" .}}
|
{{template "foot" .}}
|
||||||
|
|||||||
+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