diff --git a/projects/hutch/src/e2e/e2e-server.main.ts b/projects/hutch/src/e2e/e2e-server.main.ts index ed482d7e8..f69a2b166 100644 --- a/projects/hutch/src/e2e/e2e-server.main.ts +++ b/projects/hutch/src/e2e/e2e-server.main.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { HutchLogger, consoleLogger, noopLogger } from '@packages/hutch-logger' import { calculateReadTime, validateSaveableUrl, type ValidateSaveableUrl } from '@packages/domain/article' import { createTestApp } from '../runtime/test-app' +import { withReadplacePreparse } from '../runtime/web/pages/view/preparse-readplace-url' import { createDefaultTestAppFixture, createFakeApplyParseResult, @@ -141,7 +142,7 @@ const { app: hutchApp, auth, email } = createTestApp({ extractLinksFromPageUrl: initExtractLinksFromPageUrl({ crawlFetch, validateUrl: e2eValidateSaveableUrl }), }, shared: { - validateSaveableUrl: e2eValidateSaveableUrl, + validateSaveableUrl: withReadplacePreparse(e2eValidateSaveableUrl, { selfHost: new URL(origin).host }), appOrigin: fixture.shared.appOrigin, staticBaseUrl: fixture.shared.staticBaseUrl, httpErrorMessageMapping: fixture.shared.httpErrorMessageMapping, diff --git a/projects/hutch/src/runtime/app.ts b/projects/hutch/src/runtime/app.ts index e7834e403..ef0aa6435 100644 --- a/projects/hutch/src/runtime/app.ts +++ b/projects/hutch/src/runtime/app.ts @@ -112,6 +112,7 @@ import { initLogParseError, type ParseErrorEvent } from "@packages/hutch-infra-c import { isBlockedIpAddress, validateSaveableUrl } from "@packages/domain/article"; import { createApp } from "./server"; import { initChangelogBannerSource } from "./web/changelog-banner-source"; +import { withReadplacePreparse } from "./web/pages/view/preparse-readplace-url"; import type { BotDefenseEvent } from "./web/auth/auth.page"; import type { ConversionEvent } from "./conversions"; import type { AnalyticsEvent } from "@packages/web-analytics"; @@ -715,7 +716,9 @@ export function createHutchApp(deps?: { }); const app = createApp({ - validateSaveableUrl, + validateSaveableUrl: withReadplacePreparse(validateSaveableUrl, { + selfHost: new URL(appOrigin).host, + }), appOrigin, staticBaseUrl, hashPassword, diff --git a/projects/hutch/src/runtime/test-app.ts b/projects/hutch/src/runtime/test-app.ts index a05b5ebd6..04bdbe59d 100644 --- a/projects/hutch/src/runtime/test-app.ts +++ b/projects/hutch/src/runtime/test-app.ts @@ -24,6 +24,7 @@ import type { } from "@packages/web-test-harness"; import { useTestServer as useServerForFixture } from "@packages/web-test-harness"; import { createApp } from "./server"; +import { withReadplacePreparse } from "./web/pages/view/preparse-readplace-url"; import type { GetChangelogBanner } from "./web/changelog-banner-source"; import { initFoundingAllocation } from "./web/shared/founding-progress/founding-allocation"; import type { AnalyticsEvent } from "@packages/web-analytics"; @@ -91,7 +92,9 @@ function flattenFixtureToAppDependencies( analyticsBundle: AnalyticsBundle, ): Parameters[0] { return { - validateSaveableUrl: fixture.shared.validateSaveableUrl, + validateSaveableUrl: withReadplacePreparse(fixture.shared.validateSaveableUrl, { + selfHost: new URL(fixture.shared.appOrigin).host, + }), appOrigin: fixture.shared.appOrigin, staticBaseUrl: fixture.shared.staticBaseUrl, baseUrl: fixture.shared.appOrigin, diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.save-articles.route.test.ts b/projects/hutch/src/runtime/web/pages/queue/queue.save-articles.route.test.ts index 0725dbce0..d68124765 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.save-articles.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.save-articles.route.test.ts @@ -106,6 +106,27 @@ describe("POST /queue/save-articles", () => { expect(urls).toContain("https://example.com/b"); }); + it("unwraps a Readplace /view self-URL and saves the underlying original article", async () => { + const { testApp } = setup(); + const accessToken = await createAccessToken(testApp); + + const response = await request(testApp.server) + .post("/queue/save-articles") + .set("Accept", SIREN_MEDIA_TYPE) + .set("Authorization", `Bearer ${accessToken}`) + .field("manifest", manifest([ + { url: "http://localhost:3000/view/fagnerbrack.com/business-success" }, + ])); + + expect(response.status).toBe(200); + expect(response.body.properties).toEqual( + expect.objectContaining({ saved: 1, skipped: 0, failed: 0 }), + ); + const stored = await testApp.articleStore.findArticlesByUser({ userId: TEST_USER_ID }); + const urls = stored.articles.map((a) => a.url); + expect(urls).toEqual(["https://fagnerbrack.com/business-success"]); + }); + it("dispatches a pdf content page through the pdf pipeline", async () => { const { testApp, publishedSavePdf } = setup(); const accessToken = await createAccessToken(testApp); diff --git a/projects/hutch/src/runtime/web/pages/view/preparse-readplace-url.test.ts b/projects/hutch/src/runtime/web/pages/view/preparse-readplace-url.test.ts new file mode 100644 index 000000000..12391014f --- /dev/null +++ b/projects/hutch/src/runtime/web/pages/view/preparse-readplace-url.test.ts @@ -0,0 +1,163 @@ +import type { SaveableUrlResult, ValidateSaveableUrl } from "@packages/domain/article"; +import { initPreparseReadplaceUrl, withReadplacePreparse } from "./preparse-readplace-url"; +import { MAX_VIEW_UNWRAP_DEPTH } from "./view-path"; + +const SELF_HOST = "readplace.com"; +const preparse = initPreparseReadplaceUrl({ selfHost: SELF_HOST }); + +describe("initPreparseReadplaceUrl", () => { + it("unwraps a /view// self-URL to the original article", () => { + expect(preparse("https://readplace.com/view/fagnerbrack.com/business-success")).toBe( + "https://fagnerbrack.com/business-success", + ); + }); + + it("recursively collapses a nested self-URL", () => { + expect( + preparse("https://readplace.com/view/readplace.com/view/fagnerbrack.com/x"), + ).toBe("https://fagnerbrack.com/x"); + }); + + it("collapses an explicit https:// scheme nested in the /view path", () => { + expect(preparse("https://readplace.com/view/https://fagnerbrack.com/x")).toBe( + "https://fagnerbrack.com/x", + ); + }); + + it("decodes the article's own query separator (%3F) back into the original URL", () => { + expect(preparse("https://readplace.com/view/example.com/post%3Ffoo=bar")).toBe( + "https://example.com/post?foo=bar", + ); + }); + + it("decodes the article's fragment separator (%23) back into the original URL", () => { + expect(preparse("https://readplace.com/view/example.com/post%23section")).toBe( + "https://example.com/post#section", + ); + }); + + it("preserves a literal percent (%2525 -> %25) in the original URL", () => { + expect(preparse("https://readplace.com/view/example.com/path%2525foo")).toBe( + "https://example.com/path%25foo", + ); + }); + + it("preserves an explicit http:// article scheme", () => { + expect(preparse("https://readplace.com/view/http://example.com/x")).toBe( + "http://example.com/x", + ); + }); + + it("drops Readplace's own share-tracking query while keeping the original article", () => { + expect( + preparse( + "https://readplace.com/view/fagnerbrack.com/x?utm_source=read&utm_medium=share&utm_content=abc", + ), + ).toBe("https://fagnerbrack.com/x"); + }); + + it("reads the article from the ?url= param on the /view/ landing path", () => { + expect( + preparse(`https://readplace.com/view/?url=${encodeURIComponent("https://example.com/a")}`), + ).toBe("https://example.com/a"); + }); + + it("reads the article from the ?url= param on the /view landing path", () => { + expect( + preparse(`https://readplace.com/view?url=${encodeURIComponent("https://example.com/a")}`), + ).toBe("https://example.com/a"); + }); + + it("unwraps a self-URL nested inside the ?url= param", () => { + const inner = encodeURIComponent("https://readplace.com/view/fagnerbrack.com/x"); + expect(preparse(`https://readplace.com/view?url=${inner}`)).toBe("https://fagnerbrack.com/x"); + }); + + it("leaves the bare /view landing path (no url param) unchanged", () => { + expect(preparse("https://readplace.com/view")).toBe("https://readplace.com/view"); + }); + + it("leaves a non-article self path unchanged (blog)", () => { + expect(preparse("https://readplace.com/blog/pocket-migration")).toBe( + "https://readplace.com/blog/pocket-migration", + ); + }); + + it("leaves the queue self path unchanged", () => { + expect(preparse("https://readplace.com/queue")).toBe("https://readplace.com/queue"); + }); + + it("leaves the self homepage unchanged", () => { + expect(preparse("https://readplace.com/")).toBe("https://readplace.com/"); + }); + + it("leaves a /view URL on another host unchanged (not a wrapper)", () => { + expect(preparse("https://example.com/view/foo.com/x")).toBe("https://example.com/view/foo.com/x"); + }); + + it("leaves a malformed URL unchanged", () => { + expect(preparse("not a url")).toBe("not a url"); + }); + + it("leaves a /view path with an undecodable lone % unchanged", () => { + expect(preparse("https://readplace.com/view/example.com/path%foo")).toBe( + "https://readplace.com/view/example.com/path%foo", + ); + }); + + it("stops unwrapping past the depth cap, leaving the residual wrapper intact", () => { + const nested = `https://readplace.com/view/${"readplace.com/view/".repeat(MAX_VIEW_UNWRAP_DEPTH)}fagnerbrack.com/x`; + expect(preparse(nested)).toBe("https://readplace.com/view/fagnerbrack.com/x"); + }); + + describe("self-host is matched including port", () => { + const portPreparse = initPreparseReadplaceUrl({ selfHost: "localhost:3000" }); + + it("unwraps when the host and port match", () => { + expect(portPreparse("https://localhost:3000/view/example.com/x")).toBe( + "https://example.com/x", + ); + }); + + it("leaves a different port unchanged", () => { + expect(portPreparse("https://localhost:4000/view/example.com/x")).toBe( + "https://localhost:4000/view/example.com/x", + ); + }); + }); +}); + +describe("withReadplacePreparse", () => { + function captureValidator(): { validate: ValidateSaveableUrl; seen: () => unknown } { + let received: unknown; + const validate: ValidateSaveableUrl = (value) => { + received = value; + return { status: "ERROR", error: { code: "malformed_url", message: "stub" } }; + }; + return { validate, seen: () => received }; + } + + it("hands the unwrapped original to the wrapped validator for string input", () => { + const { validate, seen } = captureValidator(); + const decorated = withReadplacePreparse(validate, { selfHost: SELF_HOST }); + decorated("https://readplace.com/view/fagnerbrack.com/x"); + expect(seen()).toBe("https://fagnerbrack.com/x"); + }); + + it("passes non-string input straight through to the wrapped validator", () => { + const { validate, seen } = captureValidator(); + const decorated = withReadplacePreparse(validate, { selfHost: SELF_HOST }); + decorated(42); + expect(seen()).toBe(42); + }); + + it("returns the wrapped validator's result", () => { + const result: SaveableUrlResult = { + status: "ERROR", + error: { code: "private_network", message: "sentinel" }, + }; + const validate: ValidateSaveableUrl = () => result; + const decorated = withReadplacePreparse(validate, { selfHost: SELF_HOST }); + expect(decorated("https://example.com/x")).toBe(result); + }); +}); diff --git a/projects/hutch/src/runtime/web/pages/view/preparse-readplace-url.ts b/projects/hutch/src/runtime/web/pages/view/preparse-readplace-url.ts new file mode 100644 index 000000000..d7ef01c86 --- /dev/null +++ b/projects/hutch/src/runtime/web/pages/view/preparse-readplace-url.ts @@ -0,0 +1,54 @@ +import type { ValidateSaveableUrl } from "@packages/domain/article"; +import { MAX_VIEW_UNWRAP_DEPTH, originalUrlFromViewPath } from "./view-path"; + +const VIEW_PREFIX = "/view/"; + +/** `/view` and `/view/` are the landing route that carries the article in a + * `?url=` query param; every other `/view/...` path carries it in the path. */ +const LANDING_PATHS: ReadonlySet = new Set([VIEW_PREFIX, "/view"]); + +/** A Readplace reader URL (`/view//` or `/view/?url=`) wraps + * an underlying article. Saving the wrapper verbatim would store a self-referential + * record keyed on Readplace's own host. This recovers the original article URL, + * recursively collapsing nesting, so the save record, card link, dedup key, and + * `/view` identity all use the original. */ +export function initPreparseReadplaceUrl(deps: { selfHost: string }): (rawUrl: string) => string { + return function preparse(rawUrl: string): string { + let current = rawUrl; + for (let depth = 0; depth < MAX_VIEW_UNWRAP_DEPTH; depth += 1) { + let url: URL; + try { + url = new URL(current); + } catch { + return current; + } + if (url.host !== deps.selfHost) return current; + const original = unwrapSelfViewUrl(url); + if (original === undefined) return current; + current = original; + } + return current; + }; +} + +function unwrapSelfViewUrl(url: URL): string | undefined { + if (LANDING_PATHS.has(url.pathname)) { + const param = url.searchParams.get("url"); + return param === null ? undefined : param; + } + if (url.pathname.startsWith(VIEW_PREFIX)) { + return originalUrlFromViewPath(url.pathname.slice(VIEW_PREFIX.length)); + } + return undefined; +} + +/** Decorates a SaveableUrl validator so it unwraps Readplace self-URLs before + * validating. Composed at the composition roots so every save path — every + * client — mints the brand from the original article URL with no per-route code. */ +export function withReadplacePreparse( + validate: ValidateSaveableUrl, + deps: { selfHost: string }, +): ValidateSaveableUrl { + const preparse = initPreparseReadplaceUrl(deps); + return (value) => validate(typeof value === "string" ? preparse(value) : value); +} diff --git a/projects/hutch/src/runtime/web/pages/view/view-path.test.ts b/projects/hutch/src/runtime/web/pages/view/view-path.test.ts index 9c64a7cc7..4653290d4 100644 --- a/projects/hutch/src/runtime/web/pages/view/view-path.test.ts +++ b/projects/hutch/src/runtime/web/pages/view/view-path.test.ts @@ -1,4 +1,4 @@ -import { parseViewPath, viewPathFor } from "./view-path"; +import { MAX_VIEW_UNWRAP_DEPTH, originalUrlFromViewPath, parseViewPath, viewPathFor } from "./view-path"; function parse(decodedAndEncoded: string): ReturnType; function parse(args: { rawPath: string; encodedPath: string }): ReturnType; @@ -194,3 +194,36 @@ describe("parseViewPath", () => { expect(parse(rawWildcard)).toEqual({ kind: "render", articleUrl: url }); }); }); + +describe("originalUrlFromViewPath", () => { + it("recovers the https article URL from a scheme-less tail", () => { + expect(originalUrlFromViewPath("example.com/post")).toBe("https://example.com/post"); + }); + + it("decodes the percent-encoded query separator back into the article URL", () => { + expect(originalUrlFromViewPath("example.com/post%3Ffoo=bar")).toBe( + "https://example.com/post?foo=bar", + ); + }); + + it("preserves an explicit http:// scheme", () => { + expect(originalUrlFromViewPath("http://example.com/post")).toBe("http://example.com/post"); + }); + + it("resolves a redirect tail (legacy https:// prefix) to the canonical article URL", () => { + expect(originalUrlFromViewPath("https://example.com/post")).toBe("https://example.com/post"); + }); + + it("resolves a collapsed http:/ redirect tail to the http article URL", () => { + expect(originalUrlFromViewPath("http:/example.com/post")).toBe("http://example.com/post"); + }); + + it("returns undefined when the tail cannot be percent-decoded", () => { + expect(originalUrlFromViewPath("example.com/path%foo")).toBeUndefined(); + }); + + it("returns undefined when scheme-redirect resolution exceeds the depth cap", () => { + const nested = `${"https://".repeat(MAX_VIEW_UNWRAP_DEPTH + 1)}example.com/x`; + expect(originalUrlFromViewPath(nested)).toBeUndefined(); + }); +}); diff --git a/projects/hutch/src/runtime/web/pages/view/view-path.ts b/projects/hutch/src/runtime/web/pages/view/view-path.ts index 819b57274..265c19d69 100644 --- a/projects/hutch/src/runtime/web/pages/view/view-path.ts +++ b/projects/hutch/src/runtime/web/pages/view/view-path.ts @@ -44,6 +44,29 @@ export function parseViewPath(input: ParseViewPathInput): ParseViewPathResult { return { kind: "render", articleUrl: `https://${rawPath}` }; } +export const MAX_VIEW_UNWRAP_DEPTH = 16; + +/** Recovers the original article URL from a `/view/` segment, or undefined + * if the tail can't be decoded (e.g. a lone `%`). Single source of truth for the + * `/view` ↔ original mapping: it reuses parseViewPath so the `%3F`→`?`, `%23`→`#`, + * `%2525`→`%25`, and explicit-`http://` rules are not reimplemented. A redirect + * result is the same article in canonical form, so it is resolved iteratively. */ +export function originalUrlFromViewPath(tail: string): string | undefined { + let current = tail; + for (let depth = 0; depth < MAX_VIEW_UNWRAP_DEPTH; depth += 1) { + let rawPath: string; + try { + rawPath = decodeURIComponent(current); + } catch { + return undefined; + } + const result = parseViewPath({ rawPath, encodedPath: current }); + if (result.kind === "render") return result.articleUrl; + current = result.canonicalPath.slice("/view/".length); + } + return undefined; +} + /** Re-encode `%25` (literal `%`), `?`, and `#` so the canonical survives * Express's `decodeURIComponent` on the wildcard param. `%25` is the URL * constructor's encoding of a literal percent sign; double-encoding it to diff --git a/projects/hutch/src/runtime/web/pages/view/view.route.test.ts b/projects/hutch/src/runtime/web/pages/view/view.route.test.ts index c0695810b..fb07b3669 100644 --- a/projects/hutch/src/runtime/web/pages/view/view.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/view/view.route.test.ts @@ -183,6 +183,22 @@ describe("View routes", () => { expect(time.textContent).toBe("26 Mar '26, 14:32"); }); + it("unwraps a nested Readplace self-URL and renders the underlying article", async () => { + const harness = buildReaderHarness(); + const selfHost = new URL(TEST_APP_ORIGIN).host; + + const response = await request(harness.server).get( + `/view/${selfHost}/view/${CANONICAL_PATH}`, + ); + + expect(response.status).toBe(200); + const doc = new JSDOM(response.text).window.document; + const href = ctaAction(doc).getAttribute("href"); + assert(href, "cta action must have an href"); + const parsed = new URL(href, "http://localhost"); + expect(parsed.searchParams.get("url")).toBe(ARTICLE_URL); + }); + it("301-redirects the legacy percent-encoded format to the scheme-less canonical", async () => { const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));