From 6b5a3700d8464033abc8c5ef696db4b853e265b0 Mon Sep 17 00:00:00 2001 From: FlintWave Date: Tue, 9 Jun 2026 16:27:28 -0700 Subject: [PATCH] fix(server): keep results when applying a scope/rule from the served page Applying a scope or a per-result block/raise/lower/pin rule on the served search page dumped the user on the home page with an empty box, losing the results. `_redirect_back` derives its target from the Referer, but every response sets `Referrer-Policy: no-referrer`, so the Referer is stripped and the redirect always fell through to "/". Carry the current search (q/sort/vertical) as hidden fields on the scope and rank forms, and redirect the mutation POSTs to `/search?q=...&vertical=... &sort=...` so the owner lands on the same results with the new ranking applied. The home-page / settings scope selector (no query) still falls back to "/". Mirrors the Android sibling fix. Regression tests assert the redirect target and the no-query fallback; ruff, mypy, and the server suite pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++ src/searchmob_desktop/server/app.py | 20 +++++++++-- src/searchmob_desktop/server/templates.py | 25 +++++++++++--- tests/server/test_rules_endpoints.py | 42 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e991ffd..7d4bd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to SearchMob Desktop are documented here. The version scheme ## [Unreleased] +### Fixed +- **Applying a scope or rule from the browser no longer throws away your results.** On a SearchMob + search page in your browser, changing the scope (or blocking/raising/lowering/pinning a result's + site) used to send you to the home page with an empty search box, losing the results you were + looking at. It now returns you to the same search with the new ranking applied, so you keep your + results instead of starting over. + ## 26.06.05 — 2026-06-05 ### Added diff --git a/src/searchmob_desktop/server/app.py b/src/searchmob_desktop/server/app.py index 3d6bf50..184f488 100644 --- a/src/searchmob_desktop/server/app.py +++ b/src/searchmob_desktop/server/app.py @@ -34,7 +34,7 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import asdict, dataclass, replace from ipaddress import ip_address -from urllib.parse import parse_qsl, urlsplit +from urllib.parse import parse_qsl, urlencode, urlsplit from starlette.applications import Starlette from starlette.middleware import Middleware @@ -770,6 +770,20 @@ async def _form(request: Request) -> dict[str, str]: raw = await request.body() return dict(parse_qsl(raw.decode("utf-8", "replace"))) + def _redirect_to_results(form: dict[str, str]) -> Response: + # Return to the results page a scope/rule POST came from, rebuilt from the hidden q/sort/ + # vertical fields the served forms carry, so the new ranking is applied to the same search + # instead of dumping the owner on the home page. (Our no-referrer policy strips the Referer, + # so the Referer-based _redirect_back always fell through to "/".) No query - the home-page + # or settings scope selector - falls back to home. 303 -> re-fetch with GET. + query = form.get("q", "").strip() + if not query: + return RedirectResponse("/", status_code=303) + sort_mode = form.get("sort", "").strip() or "fresh" + vertical = form.get("vertical", "").strip() or "web" + target = "/search?" + urlencode({"q": query, "vertical": vertical, "sort": sort_mode}) + return RedirectResponse(target, status_code=303) + async def set_domain_rule(request: Request) -> Response: if ranking_rules_saver is None: return PlainTextResponse("Personalization is read-only here.", status_code=503) @@ -778,7 +792,7 @@ async def set_domain_rule(request: Request) -> Response: action = form.get("action", "").strip().upper() if domain and action in RankRule.__members__: ranking_rules_saver(rules_provider().with_domain_rule(domain, RankRule[action])) - return _redirect_back(request) + return _redirect_to_results(form) async def set_scope(request: Request) -> Response: if ranking_rules_saver is None: @@ -786,7 +800,7 @@ async def set_scope(request: Request) -> Response: form = await _form(request) lens = form.get("lens", "").strip() ranking_rules_saver(rules_provider().with_active_lens(lens or None)) - return _redirect_back(request) + return _redirect_to_results(form) def _csv_tuple(raw: str) -> tuple[str, ...]: # Split a comma- or newline-separated field into clean, de-duplicated, lowercased entries. diff --git a/src/searchmob_desktop/server/templates.py b/src/searchmob_desktop/server/templates.py index b5fe087..28f8dfe 100644 --- a/src/searchmob_desktop/server/templates.py +++ b/src/searchmob_desktop/server/templates.py @@ -620,7 +620,20 @@ def _home_scope(rules: RankingRules) -> tuple[str, str]: return select, form -def _scope_bar(rules: RankingRules) -> str: +def _search_context_fields(query: str, sort_mode: str, vertical: str) -> str: + """Hidden q/sort/vertical fields so a mutation POST can return to the same results page. + + The served forms carry the current search this way because our ``Referrer-Policy: no-referrer`` + strips the Referer, so the handler cannot recover the originating page from it. + """ + return ( + f'' + f'' + f'' + ) + + +def _scope_bar(rules: RankingRules, query: str, sort_mode: str, vertical: str) -> str: """A scope (lens) selector. Renders only when the profile has at least one lens defined.""" if not rules.lenses: return "" @@ -634,7 +647,8 @@ def _scope_bar(rules: RankingRules) -> str: # onchange auto-submits when JS is on; the noscript button covers the JS-off case. return ( '
' - f'' + + _search_context_fields(query, sort_mode, vertical) + + f'' '" @@ -643,7 +657,7 @@ def _scope_bar(rules: RankingRules) -> str: ) -def _rank_controls(url: str, rules: RankingRules) -> str: +def _rank_controls(url: str, rules: RankingRules, query: str, sort_mode: str, vertical: str) -> str: """Per-result domain controls (block / lower / raise / pin / reset) as a single POST form.""" domain = host_of_url(url) if not domain: @@ -652,6 +666,7 @@ def _rank_controls(url: str, rules: RankingRules) -> str: safe_domain = escape(domain, quote=True) parts = [ '', + _search_context_fields(query, sort_mode, vertical), f'{escape(domain)}', f'', ] @@ -810,7 +825,7 @@ def render_results_page( parts.append(_engine_status_line(engine_status)) parts.append(_sort_bar(query, sort_mode)) if editable: - parts.append(_scope_bar(active_rules)) + parts.append(_scope_bar(active_rules, query, sort_mode, vertical)) for index, result in enumerate(results_list): # Results past the first reveal window start collapsed; the reveal script unhides them # in batches on scroll. The full list is still in the DOM, so click positions (and the @@ -841,7 +856,7 @@ def render_results_page( parts.append(f'{escape(name)}') parts.append("") if editable: - parts.append(_rank_controls(result.url, active_rules)) + parts.append(_rank_controls(result.url, active_rules, query, sort_mode, vertical)) parts.append("") if len(results_list) > _REVEAL_SIZE: parts.append('') diff --git a/tests/server/test_rules_endpoints.py b/tests/server/test_rules_endpoints.py index a3d9b5b..e88a0b0 100644 --- a/tests/server/test_rules_endpoints.py +++ b/tests/server/test_rules_endpoints.py @@ -126,6 +126,48 @@ def test_post_scope_sets_active_lens() -> None: assert holder.rules.active_lens is None +def test_mutation_redirects_back_to_the_results_page() -> None: + # Regression: applying a scope/rule from the results page must return to that search (carried + # via hidden q/sort/vertical fields), not dump the owner on the home page and lose the results. + # The Referer is stripped by our no-referrer policy, so the redirect cannot rely on it. + holder = _Holder(RankingRules(lenses=(Lens(name="Docs"),))) + with _loopback(_app(holder)) as client: + resp = client.post( + "/scope", + data={"lens": "Docs", "q": "privacy", "sort": "fresh", "vertical": "news"}, + follow_redirects=False, + ) + assert resp.status_code == 303 + loc = resp.headers["location"] + assert loc.startswith("/search?") + assert "q=privacy" in loc + assert "vertical=news" in loc + + resp = client.post( + "/rules/domain", + data={ + "domain": "news.example", + "action": "BLOCK", + "q": "privacy", + "sort": "fresh", + "vertical": "web", + }, + follow_redirects=False, + ) + assert resp.status_code == 303 + assert resp.headers["location"].startswith("/search?") + assert "q=privacy" in resp.headers["location"] + + +def test_mutation_without_a_query_falls_back_home() -> None: + # The home-page scope selector carries no query; it must still redirect to "/", not error. + holder = _Holder(RankingRules(lenses=(Lens(name="Docs"),))) + with _loopback(_app(holder)) as client: + resp = client.post("/scope", data={"lens": "Docs"}, follow_redirects=False) + assert resp.status_code == 303 + assert resp.headers["location"] == "/" + + def test_endpoints_are_read_only_without_a_saver() -> None: with _loopback(_app(None)) as client: resp = client.post("/rules/domain", data={"domain": "x.example", "action": "BLOCK"})