commit 626a5885eedb01595114760f3ff80705e57362e4 equwal <truex@equwal.com> 2026-09-24 00:39:42 -0700 Bookmarks page: remove the live preview Most sites do not let a page of another site show them in a frame: they send X-Frame-Options or a frame-ancestors policy. YouTube is one of them. For these sites the frame of the preview stayed empty, so the preview did not work for most bookmarks. The server cannot change this. This reverts b3d3f70. Enter opens the first result again, and the policy allows no frames. The new test TestNoFrames checks that the bookmarks page has no frame and that the policy allows none.
README.md | 16 +++------ bookmarks.go | 12 ------- main.go | 6 ++-- pages.html | 21 ------------ preview.js | 108 ----------------------------------------------------------- search.js | 14 ++++---- web_test.go | 38 +++++---------------- 7 files changed, 22 insertions(+), 193 deletions(-)
diff --git a/README.md b/README.md index 6b282f2..9b9c855 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,10 @@ browser, also on a phone. The home page then has the search box too. The page does what bm does: - Search as fzf does, with the search of the add-on. The results change - while you type, and Enter opens the highlighted result (Ctrl+Enter: in a - new tab), as in the add-on. Pick a tag. Sort by date, URL, description or + while you type, and Enter opens the first result (Ctrl+Enter: in a new + tab), as in the add-on. Pick a tag. Sort by date, URL, description or tag. Text that no bookmark matches opens as an address, or as a web search. -- Live preview, in a wide window: the page of the highlighted result shows - on the right of the list while you search. The arrow keys and the mouse - move the highlight. An http page shows with https. Some sites do not let - other pages show them, so their preview stays empty. - Search from the address bar of the browser: type sbm, a space and your words. Pages link to an OpenSearch description, `/opensearch.xml`, and the bookmarks page tells the address to add: @@ -65,11 +61,9 @@ page does what bm does: - Download the file. Each change goes through the same merge as a sync, so the changes that your -devices made since the page opened stay. The only scripts are `search.js`, -for the search while you type, which gets the results from the server, and -`preview.js`, for the live preview. Without them, the search form works too. -The browser loads the page of the preview from its site, in a sandboxed -frame without a referrer: the server does not fetch it. +devices made since the page opened stay. The only script is `search.js`, +for the search while you type: it gets the results from the server. Without +it, the search form works too. The pages obey the rules of sync: the email address must be confirmed, and with billing on, the account needs its trial or a subscription. The diff --git a/bookmarks.go b/bookmarks.go index 51cc52d..ea867e6 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -40,12 +40,6 @@ var ( //go:embed search.js var searchJS string -// previewJS is the live preview of the list page: the page of the -// highlighted bookmark in a frame. -// -//go:embed preview.js -var previewJS string - // sentence gives the text of an error as a sentence for a page. func sentence(err error) string { return strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "." @@ -754,12 +748,6 @@ func (s *server) script(w http.ResponseWriter, r *http.Request) { io.WriteString(w, searchJS) } -// previewScript gives the live preview of the list page. -func (s *server) previewScript(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/javascript; charset=utf-8") - io.WriteString(w, previewJS) -} - // openSearch describes the search of the bookmarks in the OpenSearch 1.1 // format, so that a browser can search them from its address bar. func (s *server) openSearch(w http.ResponseWriter, r *http.Request) { diff --git a/main.go b/main.go index f9e2768..261ec2d 100644 --- a/main.go +++ b/main.go @@ -179,14 +179,12 @@ func (s *server) routes() http.Handler { m.HandleFunc("POST /bookmarks/import", s.importFile) m.HandleFunc("GET /bookmarks.txt", s.download) m.HandleFunc("GET /search.js", s.script) - m.HandleFunc("GET /preview.js", s.previewScript) m.HandleFunc("GET /opensearch.xml", s.openSearch) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() // Scripts only from this server: the live search of the bookmark - // pages gets its results with fetch. Frames only with https pages: - // the live preview shows the page of a bookmark. - h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; connect-src 'self'; frame-src https:; style-src 'unsafe-inline'; "+ + // pages gets its results with fetch. + h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; "+ "form-action 'self' https://checkout.stripe.com https://billing.stripe.com; "+ "frame-ancestors 'none'; base-uri 'none'") h.Set("Referrer-Policy", "same-origin") diff --git a/pages.html b/pages.html index e6af677..14b3dab 100644 --- a/pages.html +++ b/pages.html @@ -25,18 +25,8 @@ select { font: inherit; padding: .3em; margin: .2em 0 1em; } textarea { display: block; width: 100%; box-sizing: border-box; height: 60vh; margin: .2em 0 1em; font: 13px/1.4 monospace; } details { margin: 1em 0; } summary { cursor: pointer; } .bookmarks { list-style: none; padding: 0; } .bookmarks li { margin: .8em 0; overflow-wrap: anywhere; } .bookmarks small { color: #555; } -.bookmarks li.sel { background: #e3eef3; box-shadow: 0 0 0 .3em #e3eef3; } -/* The live preview needs a wide window. There it is on the right of the list. */ -#preview { display: none; } -#preview p { margin: 0 0 .3em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#preview iframe { flex: 1; width: 100%; box-sizing: border-box; border: 1px solid #ccc; background: #fff; } -@media (min-width: 64em) { - body:has(#preview) { margin-left: 2em; } - #preview:not([hidden]) { display: flex; flex-direction: column; position: fixed; top: 1em; bottom: 1em; left: 41em; right: 1em; } -} @media (prefers-color-scheme: dark) { body { background: #111; color: #ddd; } a { color: #4fb3d9; } .note, footer, .bookmarks small { color: #999; } .error { color: #f77; } - .bookmarks li.sel { background: #1e2d34; box-shadow: 0 0 0 .3em #1e2d34; } #preview iframe { border-color: #444; } } </style> </head> @@ -160,10 +150,6 @@ their bookmark files.</p> </select></label> <button>Search</button> </form> -<section id="preview" hidden> -<p class="note">Type a search, or point at a bookmark, to see its page here.</p> -<iframe title="Preview of the page" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe> -</section> <details{{if eq .Open "add"}} open{{end}}><summary>Add a bookmark</summary> <form method="post" action="/bookmarks/add"> <label>URL <input name="url" value="{{.Form.URL}}" required></label> @@ -201,7 +187,6 @@ This page shows the first {{len .Rows}}.{{end}}{{if or .Query .Tag}} <a href="/b {{with .Tags}}<h2>Tags</h2> <p>{{range .}}<a href="/bookmarks?tag={{.Name}}">{{.Name}}</a> ({{.N}}) {{end}}</p>{{end}} <script src="/search.js" defer></script> -<script src="/preview.js" defer></script> {{template "foot" .}}{{end}} {{define "edit"}}{{template "head" .}} @@ -303,12 +288,6 @@ server deletes the account and all versions of your bookmark file at once.</p> <p>They keep your bookmarks on your device. They send them only to the sync server that you sign in to, and only after you sign in. Without an account, no data leaves your device.</p> -<h2>The live preview</h2> -<p>The bookmark page of this server, the app and the add-on show the page of -the highlighted bookmark while you search. Your browser, or the app, loads -that page from its own site, as a visit does, without a referrer. This -server does not load it and does not learn which pages you preview. The app -and the add-on can turn the preview off.</p> {{with .Contact}}<h2>Contact</h2> <p><a href="mailto:{{.}}">{{.}}</a></p>{{end}} {{template "foot" .}}{{end}} diff --git a/preview.js b/preview.js deleted file mode 100644 index 22cad2d..0000000 --- a/preview.js +++ /dev/null @@ -1,108 +0,0 @@ -// Live preview for the bookmark page of sbm Sync, as in the sbm add-on: the -// page of the highlighted bookmark shows in a frame beside the results. The -// arrow keys in the search box and the mouse move the highlight, and Enter -// opens the highlighted bookmark (search.js). search.js changes the results; -// this script follows the changes. The preview needs a wide window: in a -// narrow one, the pane stays hidden and loads no page. -"use strict"; - -(() => { - const results = document.getElementById("results"); - const pane = document.getElementById("preview"); - const form = document.getElementById("search"); - if (!results || !pane || !form) return; - const box = form.elements.q; - const frame = pane.querySelector("iframe"); - const caption = pane.querySelector("p"); - const wide = matchMedia("(min-width: 64em)"); - // A page loads only after the highlight stays on it this long (ms), so - // that fast typing does not load a page for each letter. - const wait = 300; - // A search from the address bar of the browser starts the preview at once. - // Else it starts when the user types or points at a bookmark. - let started = box.value.trim() !== ""; - let sel = 0; // the highlighted row - let timer = null; - let framed = null; // the address in the frame - let pointer = ""; // where the mouse was last - - const rows = () => [...results.querySelectorAll("li")]; - - // address gives the address that the frame loads for a row, or null. An - // http page loads with https, because a secure page cannot show an - // insecure frame. - function address(li) { - const a = li && li.querySelector("a.open"); - if (!a) return null; - const u = new URL(a.href); - if (u.protocol !== "http:" && u.protocol !== "https:") return null; - u.protocol = "https:"; - return u.href; - } - - function load(to) { - if (to === framed) return; - framed = to; - frame.src = to ?? "about:blank"; - } - - // show shows the page of row li, when the highlight stays on it. - function show(li) { - clearTimeout(timer); - if (!wide.matches) return; - const to = address(li); - caption.textContent = !li ? "No bookmark to preview." : to ?? "No preview: this bookmark is not a web page."; - if (to) timer = setTimeout(() => load(to), wait); - else load(null); - } - - function highlight(i) { - const all = rows(); - sel = Math.max(0, Math.min(all.length - 1, i)); - all.forEach((li, j) => li.classList.toggle("sel", j === sel)); - if (started) show(all[sel]); - } - - // point: the user points at row i, with the mouse, the Tab key or the - // arrow keys. - function point(i) { - started = true; - highlight(i); - } - - // New results: the highlight goes to the first row. - new MutationObserver(() => highlight(0)).observe(results, { childList: true }); - box.addEventListener("input", () => { - started = true; - }); - box.addEventListener("keydown", (e) => { - if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; - e.preventDefault(); - point(sel + (e.key === "ArrowDown" ? 1 : -1)); - const li = rows()[sel]; - if (li) li.scrollIntoView({ block: "nearest" }); - }); - results.addEventListener("mousemove", (e) => { - // The browser also sends mousemove when the page moves under a mouse - // that stays still. Only a move of the mouse moves the highlight. - const li = e.target.closest("li"); - const at = e.screenX + "," + e.screenY; - if (!li || at === pointer) return; - pointer = at; - point(rows().indexOf(li)); - }); - results.addEventListener("focusin", (e) => { - const li = e.target.closest("li"); - if (li) point(rows().indexOf(li)); - }); - wide.addEventListener("change", () => (wide.matches ? highlight(sel) : load(null))); - // A page in the frame can take the focus with a script, and then the - // typing goes into that page. The preview is only to look at, so the - // focus goes back to the search box at once. The mouse wheel and clicks - // on links still work in the frame. - window.addEventListener("blur", () => setTimeout(() => { - if (document.activeElement === frame) box.focus({ preventScroll: true }); - })); - pane.hidden = false; - highlight(0); -})(); diff --git a/search.js b/search.js index 64da094..d14f084 100644 --- a/search.js +++ b/search.js @@ -1,7 +1,6 @@ // Live search for the bookmark pages of sbm Sync: the results change while -// you type, and Enter opens the first result, as in the sbm add-on. When -// preview.js highlights a result, Enter opens that one. Ctrl+Enter opens it -// in a new tab. The server does the search: this script +// you type, and Enter opens the first result, as in the sbm add-on. +// Ctrl+Enter opens it in a new tab. The server does the search: this script // gets the page of the search and shows its results. Without this script the // form still works. "use strict"; @@ -58,10 +57,9 @@ running = update(); } await running; - const row = results.querySelector("li.sel"); - const link = row ? row.querySelector("a.open") : results.querySelector("a.open"); - if (link === null) form.submit(); - else if (e.ctrlKey || e.metaKey) window.open(link.href, "_blank", "noopener"); - else location.assign(link.href); + const first = results.querySelector("a.open"); + if (first === null) form.submit(); + else if (e.ctrlKey || e.metaKey) window.open(first.href, "_blank", "noopener"); + else location.assign(first.href); }); })(); diff --git a/web_test.go b/web_test.go index b261efc..fa35157 100644 --- a/web_test.go +++ b/web_test.go @@ -524,44 +524,24 @@ func TestLiveSearch(t *testing.T) { } } -func TestLivePreview(t *testing.T) { +// Most sites do not let a page of another site show them in a frame +// (X-Frame-Options or frame-ancestors), YouTube among them. A preview of a +// bookmark in a frame stays empty for them, so the bookmarks page has no +// frame, and the policy allows none. +func TestNoFrames(t *testing.T) { e := newEnv(t, false) - c, _, _ := e.account("https://a.org\tA\t\n") + c, _, _ := e.account("https://www.youtube.com/watch?v=aqz-KE-bpKQ\tA video\t\n") resp, err := c.Get(e.web.URL + "/bookmarks") if err != nil { t.Fatal(err) } b, _ := io.ReadAll(resp.Body) resp.Body.Close() - // The preview shows pages of other sites in a frame, but only https - // pages: an http frame in an https page is mixed content. No site may - // show this page in a frame. csp := resp.Header.Get("Content-Security-Policy") - if !strings.Contains(csp, "; frame-src https:;") || !strings.Contains(csp, "frame-ancestors 'none'") { + if !strings.Contains(csp, "default-src 'none';") || strings.Contains(csp, "frame-src") || strings.Contains(csp, "child-src") { t.Errorf("Content-Security-Policy: %q", csp) } - // The frame runs the scripts of the page, but the page cannot lead this - // page away, open windows or send forms, and it gets no referrer. The - // script shows the pane. - for _, want := range []string{`<section id="preview" hidden>`, - `<iframe title="Preview of the page" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>`, - `<script src="/preview.js" defer></script>`} { - if !strings.Contains(string(b), want) { - t.Errorf("the page has no %q", want) - } - } - resp, err = e.browser().Get(e.web.URL + "/preview.js") - if err != nil { - t.Fatal(err) - } - b, _ = io.ReadAll(resp.Body) - resp.Body.Close() - if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "text/javascript; charset=utf-8" || string(b) != previewJS { - t.Errorf("GET /preview.js: %s %q, %d bytes", resp.Status, resp.Header.Get("Content-Type"), len(b)) - } - // The home page has the search, but no room for a preview. - if home := e.body(c, "/"); !strings.Contains(home, `id="search"`) || - strings.Contains(home, `<section id="preview"`) || strings.Contains(home, "/preview.js") { - t.Error("the home page must have the search and no preview") + if strings.Contains(string(b), "<iframe") { + t.Error("the bookmarks page has a frame") } }