Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion projects/hutch/src/e2e/e2e-server.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion projects/hutch/src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -715,7 +716,9 @@ export function createHutchApp(deps?: {
});

const app = createApp({
validateSaveableUrl,
validateSaveableUrl: withReadplacePreparse(validateSaveableUrl, {
selfHost: new URL(appOrigin).host,
}),
appOrigin,
staticBaseUrl,
hashPassword,
Expand Down
5 changes: 4 additions & 1 deletion projects/hutch/src/runtime/test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,7 +92,9 @@ function flattenFixtureToAppDependencies(
analyticsBundle: AnalyticsBundle,
): Parameters<typeof createApp>[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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<host>/<path> 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);
});
});
Original file line number Diff line number Diff line change
@@ -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<string> = new Set([VIEW_PREFIX, "/view"]);

/** A Readplace reader URL (`/view/<host>/<path>` or `/view/?url=<encoded>`) 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);
}
35 changes: 34 additions & 1 deletion projects/hutch/src/runtime/web/pages/view/view-path.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parseViewPath>;
function parse(args: { rawPath: string; encodedPath: string }): ReturnType<typeof parseViewPath>;
Expand Down Expand Up @@ -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();
});
});
23 changes: 23 additions & 0 deletions projects/hutch/src/runtime/web/pages/view/view-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tail>` 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
Expand Down
16 changes: 16 additions & 0 deletions projects/hutch/src/runtime/web/pages/view/view.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Loading