diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index be82666fc..4de84ecd1 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -1052,6 +1052,7 @@ export function createApp(dependencies: AppDependencies): Express { const viewRouter = initViewRoutes({ validateSaveableUrl: deps.validateSaveableUrl, appOrigin, + secureCookies, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, readArticleContent: deps.readArticleContent, diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts index f419f1c95..273363b89 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts @@ -35,6 +35,8 @@ import { LoginPage } from "./auth.component"; import { initFetchUserCount } from "./fetch-user-count"; import { ClickAttributionSchema, readClickAttribution } from "@packages/web-analytics"; import { PENDING_SAVE_COOKIE_NAME, readPendingSaveId } from "../pending-save"; +import { LAST_VIEW_COOKIE_NAME, readLastViewUrl } from "../last-view"; +import { resolvePostSignupRedirect } from "./post-signup-redirect"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; import { signState, verifyState } from "./oauth-state"; @@ -59,6 +61,7 @@ const StatePayloadSchema = z.object({ attribution: ClickAttributionSchema.optional(), visitorId: z.string().optional(), pendingSaveId: z.string().optional(), + lastViewUrl: z.string().optional(), }); const STATE_COOKIE = "hutch_astate"; @@ -117,6 +120,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const attribution = readClickAttribution(req); const visitorId = req.visitorId; const pendingSaveId = readPendingSaveId(req); + const lastViewUrl = readLastViewUrl(req); const statePayload = JSON.stringify({ nonce, returnUrl, @@ -124,6 +128,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...(attribution ? { attribution } : {}), ...(visitorId ? { visitorId } : {}), ...(pendingSaveId ? { pendingSaveId } : {}), + ...(lastViewUrl ? { lastViewUrl } : {}), }); const signedState = signState({ payload: statePayload, secret: deps.stateSigningSecret }); @@ -237,6 +242,11 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { * the response Set-Cookie still applies to readplace.com — clear it so the * consumed save cannot re-attach to a later signup. */ const clearPendingSave = () => res.clearCookie(PENDING_SAVE_COOKIE_NAME, { path: "/" }); + /* Same rationale as clearPendingSave: hutch_lastview is same-site Lax so it + * is not sent on this cross-site POST, but the response Set-Cookie still + * applies to readplace.com — clear it so the tunneled URL cannot auto-save + * onto a later signup. */ + const clearLastView = () => res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" }); const userCount = await fetchUserCount(); if (!deps.foundingAllocation.isFoundingAllocationExhausted(userCount)) { @@ -268,6 +278,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true }); res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions); clearPendingSave(); + clearLastView(); sendWelcomeEmail(tokenResult.email); emitUserCreated( { logger: deps.conversionLogger, now: deps.now }, @@ -279,7 +290,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...conversionContext, }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl })); return; } @@ -338,6 +349,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true }); res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions); clearPendingSave(); + clearLastView(); sendWelcomeEmail(tokenResult.email); emitUserCreated( { logger: deps.conversionLogger, now: deps.now }, @@ -349,7 +361,7 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { ...conversionContext, }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl: stateData.lastViewUrl })); }); return router; diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts index 92533e814..03185592f 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts @@ -59,6 +59,7 @@ function freshState(overrides?: { attribution?: Record; visitorId?: string; pendingSaveId?: string; + lastViewUrl?: string; }) { return { nonce: "test-nonce", @@ -67,6 +68,7 @@ function freshState(overrides?: { ...(overrides?.attribution ? { attribution: overrides.attribution } : {}), ...(overrides?.visitorId ? { visitorId: overrides.visitorId } : {}), ...(overrides?.pendingSaveId ? { pendingSaveId: overrides.pendingSaveId } : {}), + ...(overrides?.lastViewUrl ? { lastViewUrl: overrides.lastViewUrl } : {}), }; } @@ -527,6 +529,72 @@ describe("Apple auth routes", () => { expect(passwordCheck.ok).toBe(true); }); + describe("first-article autosave", () => { + const ARTICLE_URL = "https://example.com/post"; + const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + + it("tunnels the last-viewed url into the signed state at GET so it survives the cross-site callback", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith() }); + + const getResponse = await request(harness.server) + .get("/auth/apple") + .set("Cookie", `hutch_lastview=${encodeURIComponent(ARTICLE_URL)}`); + + expect(getResponse.status).toBe(303); + const state = readSetCookie(getResponse, "hutch_astate"); + assert(state, "GET must set a state cookie carrying the tunneled last-view url"); + const payload = JSON.parse(state.slice(0, state.lastIndexOf("."))); + expect(payload.lastViewUrl).toBe(ARTICLE_URL); + }); + + it("auto-saves the tunneled article for a new Apple user with no explicit return, and clears the cookie", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-apple@example.com" })) }); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState({ lastViewUrl: ARTICLE_URL })); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + expect(cookiesFrom(response).join(";")).toContain("hutch_lastview=;"); + }, 30000); + + it("lets an explicit return URL win over the tunneled autosave", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-return-apple@example.com" })) }); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState({ lastViewUrl: ARTICLE_URL, returnUrl: "/oauth/authorize?client_id=test" })); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/oauth/authorize?client_id=test"); + }, 30000); + + it("redirects to a plain /queue when the state carries no last-view url", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "autosave-plain-apple@example.com" })) }); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState()); + + const response = await postCallback(harness.server, { + state, + cookie: `hutch_astate=${encodeURIComponent(state)}`, + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + }); + it("tunnels attribution, visitor id, and pending-save id captured at GET through the cross-site callback", async () => { const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "tunnel@example.com" })) }); diff --git a/projects/hutch/src/runtime/web/auth/auth.page.ts b/projects/hutch/src/runtime/web/auth/auth.page.ts index a1cc60936..816906578 100644 --- a/projects/hutch/src/runtime/web/auth/auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/auth.page.ts @@ -67,6 +67,8 @@ import { initValidateSignup } from "./validate-signup"; import type { FoundingAllocation } from "../shared/founding-progress/founding-allocation"; import { readClickAttribution } from "@packages/web-analytics"; import { consumePendingSaveId } from "../pending-save"; +import { consumeLastViewUrl, LAST_VIEW_COOKIE_NAME } from "../last-view"; +import { resolvePostSignupRedirect } from "./post-signup-redirect"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; @@ -334,7 +336,8 @@ export function initAuthRoutes(deps: AuthDependencies): Router { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: returnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl })); return; } @@ -389,7 +392,8 @@ export function initAuthRoutes(deps: AuthDependencies): Router { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: returnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl, lastViewUrl })); }); router.get("/auth/checkout/success", async (req: Request, res: Response) => { @@ -524,6 +528,7 @@ export function initAuthRoutes(deps: AuthDependencies): Router { await deps.destroySession(sessionId); } res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" }); res.redirect(303, "/"); }); diff --git a/projects/hutch/src/runtime/web/auth/auth.route.test.ts b/projects/hutch/src/runtime/web/auth/auth.route.test.ts index 6369c325e..75c299a1c 100644 --- a/projects/hutch/src/runtime/web/auth/auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/auth.route.test.ts @@ -828,6 +828,104 @@ describe("Auth routes", () => { }); + describe("POST /signup — first-article autosave", () => { + const ARTICLE_URL = "https://example.com/post"; + const VIEW_PATH = "/view/example.com/post"; + const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + + function lastViewSetCookie(response: request.Response): string | undefined { + const setCookie = response.headers["set-cookie"]; + const cookies = Array.isArray(setCookie) ? setCookie : []; + return cookies.find((c) => c.startsWith("hutch_lastview=")); + } + + it("redirects a free signup to auto-save the article the visitor just viewed, and clears the cookie", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const agent = request.agent(harness.server); + await agent.get(VIEW_PATH); + + const response = await agent.post("/signup").type("form").send({ + email: "autosave-free@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + const cleared = lastViewSetCookie(response); + assert(cleared, "signup must clear hutch_lastview after consuming it"); + expect(cleared.startsWith("hutch_lastview=;")).toBe(true); + }, 30000); + + it("lets an explicit ?return= win over the autosave even when the cookie is present", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + const agent = request.agent(harness.server); + await agent.get(VIEW_PATH); + + const response = await agent.post("/signup?return=%2Foauth%2Fauthorize").type("form").send({ + email: "autosave-return@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/oauth/authorize"); + }, 30000); + + it("redirects a free signup to a plain /queue when no hutch_lastview cookie is present", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + + const response = await request(harness.server).post("/signup").type("form").send({ + email: "autosave-none@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + + it("ignores a tampered hutch_lastview cookie and redirects to a plain /queue", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + + const response = await request(harness.server) + .post("/signup") + .type("form") + .set("Cookie", "hutch_lastview=not-a-url") + .send({ + email: "autosave-tampered@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + + it("auto-saves through the trial signup branch when the founding allocation is exhausted", async () => { + const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); + for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) { + await harness.auth.createUser({ email: `seed${i}@test.com`, password: "password123" }); + } + const agent = request.agent(harness.server); + await agent.get(VIEW_PATH); + + const response = await agent.post("/signup").type("form").send({ + email: "autosave-trial@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + }, 30000); + }); + describe("POST /signup — bot defense", () => { it("returns a fake-success 303 to /?signup=pending and logs a 'honeypot' rejection when the hidden website field is filled", async () => { const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); @@ -1184,6 +1282,12 @@ describe("Auth routes", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe("/"); + const setCookie = response.headers["set-cookie"]; + const cookies = Array.isArray(setCookie) ? setCookie : []; + assert( + cookies.some((c) => c.startsWith("hutch_lastview=;")), + "logout must clear hutch_lastview so it cannot auto-save onto a later signup", + ); }); it("should handle logout when no session cookie exists", async () => { diff --git a/projects/hutch/src/runtime/web/auth/google-auth.page.ts b/projects/hutch/src/runtime/web/auth/google-auth.page.ts index 19a6c5c80..9a4a08abe 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.page.ts @@ -34,6 +34,8 @@ import { LoginPage } from "./auth.component"; import { initFetchUserCount } from "./fetch-user-count"; import { readClickAttribution } from "@packages/web-analytics"; import { consumePendingSaveId } from "../pending-save"; +import { consumeLastViewUrl } from "../last-view"; +import { resolvePostSignupRedirect } from "./post-signup-redirect"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; import { signState, verifyState } from "./oauth-state"; @@ -218,7 +220,8 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl })); return; } @@ -284,7 +287,8 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => { pendingSaveId: consumePendingSaveId({ req, res }), }, ); - res.redirect(303, parseReturnUrl({ return: safeReturnUrl })); + const lastViewUrl = consumeLastViewUrl({ req, res }); + res.redirect(303, resolvePostSignupRedirect({ returnUrl: safeReturnUrl, lastViewUrl })); }); return router; diff --git a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts index 823b64300..d0d595399 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts @@ -243,6 +243,63 @@ describe("Google auth routes", () => { expect(consumed).toBeNull(); }, 30000); + describe("first-article autosave", () => { + const ARTICLE_URL = "https://example.com/post"; + const AUTOSAVE_LOCATION = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + + function newUserHarness() { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + return useApp({ + ...fixture, + google: { + exchangeGoogleCode: stubExchange({ email: "autosave-google@example.com" }), + clientId: TEST_CLIENT_ID, + clientSecret: TEST_CLIENT_SECRET, + }, + }); + } + + it("auto-saves the last-viewed article for a new Google user with no explicit return", async () => { + const harness = newUserHarness(); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState()); + + const response = await request(harness.server) + .get(`/auth/google/callback?code=test-code&state=${encodeURIComponent(state)}`) + .set("Cookie", `hutch_gstate=${encodeURIComponent(state)}; hutch_lastview=${encodeURIComponent(ARTICLE_URL)}`); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe(AUTOSAVE_LOCATION); + expect(cookiesFrom(response).join(";")).toContain("hutch_lastview=;"); + }, 30000); + + it("lets an explicit return URL win over the autosave", async () => { + const harness = newUserHarness(); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState({ returnUrl: "/oauth/authorize?client_id=test" })); + + const response = await request(harness.server) + .get(`/auth/google/callback?code=test-code&state=${encodeURIComponent(state)}`) + .set("Cookie", `hutch_gstate=${encodeURIComponent(state)}; hutch_lastview=${encodeURIComponent(ARTICLE_URL)}`); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/oauth/authorize?client_id=test"); + }, 30000); + + it("redirects to a plain /queue when no hutch_lastview cookie is present", async () => { + const harness = newUserHarness(); + await harness.auth.createUser({ email: "seed1@test.com", password: "password123" }); + const state = signState(freshState()); + + const response = await request(harness.server) + .get(`/auth/google/callback?code=test-code&state=${encodeURIComponent(state)}`) + .set("Cookie", `hutch_gstate=${encodeURIComponent(state)}`); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/queue"); + }, 30000); + }); + it("logs in the existing user when a race condition causes createGoogleUser to fail during free signup", async () => { const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); let raceFindCount = 0; diff --git a/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts b/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts new file mode 100644 index 000000000..b0a1fffa5 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/post-signup-redirect.test.ts @@ -0,0 +1,35 @@ +import { resolvePostSignupRedirect } from "./post-signup-redirect"; + +const ARTICLE_URL = "https://example.com/post"; +const AUTOSAVE = `/queue?url=${encodeURIComponent(ARTICLE_URL)}&utm_source=signup-autosave`; + +describe("resolvePostSignupRedirect", () => { + it("honours an explicit returnUrl and never auto-saves, even when a lastViewUrl is present", () => { + expect( + resolvePostSignupRedirect({ returnUrl: "/oauth/authorize?client_id=test", lastViewUrl: ARTICLE_URL }), + ).toBe("/oauth/authorize?client_id=test"); + }); + + it("falls back to /queue when there is neither a returnUrl nor a lastViewUrl", () => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: undefined })).toBe("/queue"); + }); + + it("auto-saves the last-viewed article when there is no explicit returnUrl", () => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL })).toBe(AUTOSAVE); + }); + + it("carries the utm_source marker and url-encodes the article url", () => { + const location = resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: ARTICLE_URL }); + const params = new URL(location, "http://localhost").searchParams; + expect(params.get("url")).toBe(ARTICLE_URL); + expect(params.get("utm_source")).toBe("signup-autosave"); + }); + + it.each([ + ["a non-url", "not a url"], + ["a javascript: scheme", "javascript:alert(1)"], + ["a loopback address", "http://localhost"], + ])("ignores %s lastViewUrl and falls back to /queue", (_label, tampered) => { + expect(resolvePostSignupRedirect({ returnUrl: undefined, lastViewUrl: tampered })).toBe("/queue"); + }); +}); diff --git a/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts b/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts new file mode 100644 index 000000000..832be0dd1 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/post-signup-redirect.ts @@ -0,0 +1,24 @@ +import { validateSaveableUrl } from "@packages/domain/article"; +import { parseReturnUrl } from "./parse-return-url"; + +const AUTOSAVE_MARKER: [string, string] = ["utm_source", "signup-autosave"]; + +/** Where a fresh signup lands. An explicit `?return=` always wins (the /save + * round-trip already carries and saves the held article in that case, so + * auto-saving too would double-save). Otherwise, when the anonymous visitor was + * reading an article just before signing up, redirect through /queue?url=… so + * the auto-submit save form drops that first article into the new empty queue. + * The URL is re-validated with the same validator used everywhere else so a + * tampered cookie can never reach the save. */ +export function resolvePostSignupRedirect(params: { + returnUrl: string | undefined; + lastViewUrl: string | undefined; +}): string { + const fallback = parseReturnUrl({ return: params.returnUrl }); + if (params.returnUrl !== undefined) return fallback; + if (params.lastViewUrl === undefined) return fallback; + const validation = validateSaveableUrl(params.lastViewUrl); + if (validation.status === "ERROR") return fallback; + const qs = new URLSearchParams([["url", validation.url], AUTOSAVE_MARKER]); + return `/queue?${qs.toString()}`; +} diff --git a/projects/hutch/src/runtime/web/last-view.test.ts b/projects/hutch/src/runtime/web/last-view.test.ts new file mode 100644 index 000000000..8fc4d51ff --- /dev/null +++ b/projects/hutch/src/runtime/web/last-view.test.ts @@ -0,0 +1,52 @@ +import { LAST_VIEW_COOKIE_NAME, consumeLastViewUrl, readLastViewUrl } from "./last-view"; + +const ARTICLE_URL = "https://example.com/post"; + +function recordingRes(): { + res: { clearCookie: (name: string, options: { path: string }) => void }; + cleared: { name: string; options: { path: string } }[]; +} { + const cleared: { name: string; options: { path: string } }[] = []; + return { + res: { clearCookie: (name, options) => { cleared.push({ name, options }); } }, + cleared, + }; +} + +describe("readLastViewUrl", () => { + it("returns the url from a present cookie", () => { + expect(readLastViewUrl({ cookies: { [LAST_VIEW_COOKIE_NAME]: ARTICLE_URL } })).toBe(ARTICLE_URL); + }); + + it("returns undefined when there is no cookie jar", () => { + expect(readLastViewUrl({})).toBeUndefined(); + }); + + it("returns undefined when the cookie is absent", () => { + expect(readLastViewUrl({ cookies: {} })).toBeUndefined(); + }); + + it("treats a non-string cookie as absent", () => { + expect(readLastViewUrl({ cookies: { [LAST_VIEW_COOKIE_NAME]: 42 } })).toBeUndefined(); + }); +}); + +describe("consumeLastViewUrl", () => { + it("returns the url and clears the cookie so it auto-saves exactly once", () => { + const { res, cleared } = recordingRes(); + + const url = consumeLastViewUrl({ req: { cookies: { [LAST_VIEW_COOKIE_NAME]: ARTICLE_URL } }, res }); + + expect(url).toBe(ARTICLE_URL); + expect(cleared).toEqual([{ name: LAST_VIEW_COOKIE_NAME, options: { path: "/" } }]); + }); + + it("clears the cookie even when none is present so a stale value cannot linger", () => { + const { res, cleared } = recordingRes(); + + const url = consumeLastViewUrl({ req: { cookies: {} }, res }); + + expect(url).toBeUndefined(); + expect(cleared).toEqual([{ name: LAST_VIEW_COOKIE_NAME, options: { path: "/" } }]); + }); +}); diff --git a/projects/hutch/src/runtime/web/last-view.ts b/projects/hutch/src/runtime/web/last-view.ts new file mode 100644 index 000000000..4de293e7f --- /dev/null +++ b/projects/hutch/src/runtime/web/last-view.ts @@ -0,0 +1,40 @@ +import type { Response } from "express"; +import { baseCookieOptions } from "@packages/web-analytics"; + +export const LAST_VIEW_COOKIE_NAME = "hutch_lastview"; + +/** The cookie is cleared the instant it is consumed at signup (see + * consumeLastViewUrl), so the TTL only has to bound an *abandoned* anonymous + * view — one whose visitor read an article but never signed up. Two hours is + * long enough for a reader to finish the article and decide to sign up, yet + * short enough that a stale last-view cannot linger and auto-save itself onto an + * unrelated later signup on the same browser. */ +const LAST_VIEW_COOKIE_MAX_AGE_MS = 2 * 60 * 60 * 1000; + +export function setLastViewUrl(deps: { res: Response; secure: boolean }, url: string): void { + deps.res.cookie(LAST_VIEW_COOKIE_NAME, url, { + ...baseCookieOptions(deps.secure), + maxAge: LAST_VIEW_COOKIE_MAX_AGE_MS, + }); +} + +export function readLastViewUrl(req: { cookies?: Record }): string | undefined { + const raw = req.cookies?.[LAST_VIEW_COOKIE_NAME]; + if (typeof raw !== "string") return undefined; + return raw; +} + +/** Reads the last-viewed URL and clears the cookie in the same step so it can + * auto-save exactly once. Without clearing, a second signup on the same browser + * within the cookie's TTL would inherit the first visitor's article. The clear + * is unconditional (matching the pending-save/logout/oauth-state cookie clears) + * and uses path "/" to match the cookie `setLastViewUrl` writes via + * `baseCookieOptions`. */ +export function consumeLastViewUrl(deps: { + req: { cookies?: Record }; + res: { clearCookie: (name: string, options: { path: string }) => void }; +}): string | undefined { + const url = readLastViewUrl(deps.req); + deps.res.clearCookie(LAST_VIEW_COOKIE_NAME, { path: "/" }); + return url; +} diff --git a/projects/hutch/src/runtime/web/pages/view/view.page.ts b/projects/hutch/src/runtime/web/pages/view/view.page.ts index 54b6c9898..2d1a5f32a 100644 --- a/projects/hutch/src/runtime/web/pages/view/view.page.ts +++ b/projects/hutch/src/runtime/web/pages/view/view.page.ts @@ -40,6 +40,7 @@ import { Base } from "../../base.component"; import type { BuildBannerState } from "../../banner-state"; import { extensionInstallUrlIfMissing, isExtensionInstalled } from "../../onboarding/extension-install"; +import { setLastViewUrl } from "../../last-view"; import { initArticleReader } from "../../shared/article-reader/article-reader"; import type { ArticleReaderDeps, @@ -57,6 +58,7 @@ import { ViewPage, formatViewDocumentTitle, type ViewAction } from "./view.compo interface ViewDependencies { validateSaveableUrl: ValidateSaveableUrl; appOrigin: string; + secureCookies: boolean; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; readArticleContent: ReadArticleContent; @@ -239,6 +241,9 @@ function handleViewArticle(deps: ViewDependencies, reader: ReturnType { }); }); + describe("hutch_lastview cookie for first-article autosave", () => { + function lastViewCookie(response: request.Response): string | undefined { + const setCookie = response.headers["set-cookie"]; + const cookies = Array.isArray(setCookie) ? setCookie : []; + return cookies.find((c) => c.startsWith("hutch_lastview=")); + } + + it("sets hutch_lastview to the article url on an anonymous, non-bot open", async () => { + const harness = buildReaderHarness(); + + const response = await request(harness.server).get(`/view/${CANONICAL_PATH}`); + + expect(response.status).toBe(200); + const cookie = lastViewCookie(response); + assert(cookie, "an anonymous open must set hutch_lastview"); + expect(decodeURIComponent(cookie.slice("hutch_lastview=".length).split(";")[0])).toBe(ARTICLE_URL); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Max-Age=7200"); + expect(cookie).toContain("Path=/"); + }); + + it("does not set hutch_lastview when an authenticated viewer opens the reader", async () => { + const harness = buildReaderHarness(); + await harness.auth.createUser({ email: "reader2@example.com", password: "password123" }); + const agent = request.agent(harness.server); + await agent.post("/login").type("form").send({ email: "reader2@example.com", password: "password123" }); + + const response = await agent.get(`/view/${CANONICAL_PATH}`); + + expect(response.status).toBe(200); + expect(lastViewCookie(response)).toBeUndefined(); + }); + + it("does not set hutch_lastview for a bot user-agent (behind the same gate as view_opened)", async () => { + const harness = buildReaderHarness(); + + const response = await request(harness.server) + .get(`/view/${CANONICAL_PATH}`) + .set("User-Agent", GOOGLEBOT); + + expect(response.status).toBe(200); + expect(lastViewCookie(response)).toBeUndefined(); + }); + }); + describe("prefetch and bot requests do not trigger the paid crawl", () => { it("skips the crawl cascade for a Sec-Purpose: prefetch request", async () => { const harness = buildReaderHarness();