diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 000000000..6d0846424 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,37 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "Folo" + +[setup] +script = ''' +pnpm i +''' + +[[actions]] +name = "web dev" +icon = "run" +command = "pnpm dev:web" + +[[actions]] +name = "electron dev" +icon = "run" +command = ''' +cd apps/desktop +pnpm dev:electron +''' + +[[actions]] +name = "ios dev" +icon = "run" +command = ''' +cd apps/mobile +pnpm ios +''' + +[[actions]] +name = "android dev" +icon = "run" +command = ''' +cd apps/mobile +pnpm android +''' diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 9ab5b3225..fb381dd0b 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -57,7 +57,7 @@ jobs: uses: pnpm/action-setup@v6 - name: 🏗 Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index fe1417385..3816beb22 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -83,7 +83,7 @@ jobs: uses: pnpm/action-setup@v6 - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 cache: "pnpm" @@ -407,7 +407,7 @@ jobs: uses: pnpm/action-setup@v6 - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/build-ios-development.yml b/.github/workflows/build-ios-development.yml index 46ca32ba3..5c16f9eca 100644 --- a/.github/workflows/build-ios-development.yml +++ b/.github/workflows/build-ios-development.yml @@ -97,7 +97,7 @@ jobs: uses: pnpm/action-setup@v6 - name: 🏗 Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 cache: "pnpm" @@ -148,7 +148,7 @@ jobs: uses: pnpm/action-setup@v6 - name: 🏗 Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index cd5defeeb..d790be1dc 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -117,7 +117,7 @@ jobs: uses: pnpm/action-setup@v6 - name: 🏗 Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 9b3c504fe..368b2cfd6 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -34,7 +34,7 @@ jobs: - uses: pnpm/action-setup@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: "pnpm" diff --git a/.github/workflows/deploy-cloudflare-desktop.yml b/.github/workflows/deploy-cloudflare-desktop.yml index 877486508..c0a0fa672 100644 --- a/.github/workflows/deploy-cloudflare-desktop.yml +++ b/.github/workflows/deploy-cloudflare-desktop.yml @@ -35,7 +35,7 @@ jobs: - uses: pnpm/action-setup@v6 - name: Use Node.js LTS - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" diff --git a/.github/workflows/deploy-cloudflare-landing.yml b/.github/workflows/deploy-cloudflare-landing.yml index a9cd90314..187ccd4f4 100644 --- a/.github/workflows/deploy-cloudflare-landing.yml +++ b/.github/workflows/deploy-cloudflare-landing.yml @@ -39,7 +39,7 @@ jobs: - uses: pnpm/action-setup@v6 - name: Use Node.js LTS - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" diff --git a/.github/workflows/deploy-cloudflare-ssr.yml b/.github/workflows/deploy-cloudflare-ssr.yml index 1671fe558..32ae35df2 100644 --- a/.github/workflows/deploy-cloudflare-ssr.yml +++ b/.github/workflows/deploy-cloudflare-ssr.yml @@ -42,7 +42,7 @@ jobs: - uses: pnpm/action-setup@v6 - name: Use Node.js LTS - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: lts/* cache: "pnpm" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ff1af2bad..4b7ba8eac 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -39,7 +39,7 @@ jobs: - uses: pnpm/action-setup@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: "pnpm" diff --git a/.github/workflows/publish-ota.yml b/.github/workflows/publish-ota.yml index c09ad4733..100a5aed7 100644 --- a/.github/workflows/publish-ota.yml +++ b/.github/workflows/publish-ota.yml @@ -60,7 +60,7 @@ jobs: uses: pnpm/action-setup@v6 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index a9d6abf63..24f1c7c0a 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: lts/* diff --git a/.gitignore b/.gitignore index e62bd9c74..490648a22 100644 --- a/.gitignore +++ b/.gitignore @@ -37,8 +37,11 @@ apps/desktop/resources/cli .wrangler -# Local agent artifacts -.codex/ +# Local agent artifacts, except the shared Codex environment +.codex/* +!.codex/environments/ +.codex/environments/* +!.codex/environments/environment.toml # E2E outputs /apps/desktop/e2e/playwright-report/ diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 000000000..21feb5768 --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,2 @@ +# Files copied by Codex into managed worktrees +apps/desktop/.env diff --git a/apps/desktop/changelog/1.11.0.md b/apps/desktop/changelog/1.11.0.md new file mode 100644 index 000000000..1c8e9d434 --- /dev/null +++ b/apps/desktop/changelog/1.11.0.md @@ -0,0 +1,13 @@ +# What's new in v1.11.0 + +## Improvements + +- Upgraded the desktop runtime to Electron 43 and refreshed core dependencies + +## No longer broken + +- Fixed YouTube embeds in entry content and readability view failing to play due to a missing referrer + +## Thanks + +Special thanks to volunteer contributor @TonyRL for the YouTube embed fix diff --git a/apps/desktop/forge.config.cts b/apps/desktop/forge.config.cts index 8237ffa68..ea509deee 100644 --- a/apps/desktop/forge.config.cts +++ b/apps/desktop/forge.config.cts @@ -36,7 +36,43 @@ const ymlMapsMap = { win32: "latest.yml", } -const keepModules = new Set(["font-list", "vscode-languagedetection"]) +// Keep external runtime modules and their production dependency trees in app.asar. +// Scoped packages are copied as a whole because cleanSources operates on top-level entries. +const keepModules = new Set([ + "@asamuzakjp", + "@bramus", + "@csstools", + "@exodus", + "bidi-js", + "css-tree", + "data-urls", + "decimal.js", + "entities", + "font-list", + "html-encoding-sniffer", + "is-potential-custom-element-name", + "jsdom", + "lru-cache", + "mdn-data", + "parse5", + "punycode", + "require-from-string", + "saxes", + "source-map-js", + "symbol-tree", + "tldts", + "tldts-core", + "tough-cookie", + "tr46", + "undici", + "vscode-languagedetection", + "w3c-xmlserializer", + "webidl-conversions", + "whatwg-mimetype", + "whatwg-url", + "xml-name-validator", + "xmlchars", +]) const keepLanguages = new Set(["en", "en_GB", "en-US", "en_US"]) // remove folders & files not to be included in the app @@ -93,7 +129,10 @@ async function cleanSources(buildPath, _electronVersion, platform, _arch, callba const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback() -const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`) +const keepModulePattern = [...keepModules] + .map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|") +const ignorePattern = new RegExp(`^/node_modules/(?!(?:${keepModulePattern})(?:/|$))`) const config: ForgeConfig = { packagerConfig: { diff --git a/apps/desktop/layer/main/src/ipc/services/integration.test.ts b/apps/desktop/layer/main/src/ipc/services/integration.test.ts index d4c60c08f..03140174f 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.test.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.test.ts @@ -1,8 +1,9 @@ import fsp from "node:fs/promises" import os from "node:os" +import { shell } from "electron" import path from "pathe" -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { IntegrationService } from "./integration" @@ -69,4 +70,70 @@ describe("IntegrationService", () => { fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1")), ).rejects.toThrow() }) + + describe("openURLScheme", () => { + const openExternalMock = vi.mocked(shell.openExternal) + + beforeEach(() => { + openExternalMock.mockReset() + openExternalMock.mockResolvedValue() + }) + + it("rejects input that cannot be parsed as a URL", async () => { + const service = new IntegrationService() + + await expect(service.openURLScheme("not-a-url")).rejects.toThrow(/Invalid URL scheme/i) + expect(openExternalMock).not.toHaveBeenCalled() + }) + + // These are the dangerous protocols that previously slipped through the + // "contains ://" guard and reached shell.openExternal verbatim. + // shell.openExternal docs explicitly warn that passing untrusted URLs is + // unsafe — file://, smb://, search-ms:, ms-msdt:, jar:, res:, etc. have + // been used in real-world RCE / NTLM-credential-theft chains. + it.each([ + ["file:///etc/passwd"], + ["FILE:///etc/passwd"], + ["smb://attacker.example/share"], + ["jar:http://attacker.example/x.jar!/"], + ["res://shell32.dll/1"], + ["ms-msdt:/id PCWDiagnostic"], + ["search-ms:query=secret"], + ["javascript:alert(1)"], + ["data:text/html,"], + ["vbscript:msgbox(1)"], + ])( + "blocks dangerous scheme %s and does not invoke shell.openExternal", + async (dangerousScheme) => { + const service = new IntegrationService() + + await expect(service.openURLScheme(dangerousScheme)).rejects.toThrow( + /not allowed|disallowed|not permitted/i, + ) + expect(openExternalMock).not.toHaveBeenCalled() + }, + ) + + // The integration UI ships these schemes as built-in examples + // (see url-scheme-handler.ts#getExamples) plus generic web/mail. + // They must keep working after the fix. + it.each([ + ["https://example.com"], + ["http://example.com/path?q=1"], + ["mailto:user@example.com"], + ["obsidian://new?vault=MyVault&name=Test"], + ["bear://x-callback-url/create?title=Test"], + ["things:///add?title=Test"], + ["notion://new?title=Test"], + ["x-devonthink://createText?title=Test"], + ["drafts://x-callback-url/create?text=Test"], + ])("permits known integration scheme %s", async (allowedScheme) => { + const service = new IntegrationService() + + await expect(service.openURLScheme(allowedScheme)).resolves.toEqual({ + success: true, + }) + expect(openExternalMock).toHaveBeenCalledWith(allowedScheme) + }) + }) }) diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index 90e97f1f2..047476e53 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -81,6 +81,29 @@ export async function saveMediaToEagle(input: SaveToEagleInput): Promise { } } +// Allowlist of URL scheme protocols that `openURLScheme` is permitted to hand +// off to `shell.openExternal`. The list intentionally covers the integrations +// shipped in the UI (Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus +// generic web/mail schemes, while excluding dangerous protocols such as +// `file:`, `smb:`, `ms-msdt:`, `search-ms:`, `jar:`, `res:`, `javascript:`, +// `data:`, `vbscript:`, which have known abuse chains when invoked from +// untrusted content. +const ALLOWED_URL_SCHEME_PROTOCOLS = new Set([ + "http", + "https", + "mailto", + "obsidian", + "bear", + "drafts", + "things", + "notion", + "x-devonthink", +]) + +function isAllowedURLSchemeProtocol(protocol: string): boolean { + return ALLOWED_URL_SCHEME_PROTOCOLS.has(protocol) +} + export class IntegrationService extends IpcService { static override readonly groupName = "integration" @@ -382,11 +405,32 @@ ${content} const requestId = Math.random().toString(36).slice(2, 8) try { - // Validate URL scheme format - if (!scheme.includes("://")) { + // Parse and validate the protocol up-front. `shell.openExternal` will + // happily dispatch any scheme the OS has registered a handler for, + // including `file://`, `smb://`, `ms-msdt:`, `search-ms:`, `jar:`, + // `res:`, etc. Several of those have well-documented exploit chains + // (NTLM credential theft over SMB, MSDT/Follina RCE on Windows, + // local-file disclosure via file://). The Electron docs explicitly + // warn against passing untrusted URLs to `shell.openExternal`, so we + // enforce a strict allowlist of schemes that the integrations UI is + // intended to support. + let protocol: string + try { + protocol = new URL(scheme).protocol.replace(/:$/, "").toLowerCase() + } catch { throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')") } + if (!protocol) { + throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')") + } + + if (!isAllowedURLSchemeProtocol(protocol)) { + throw new Error( + `URL scheme "${protocol}://" is not allowed. Allowed schemes: ${[...ALLOWED_URL_SCHEME_PROTOCOLS].sort().join(", ")}.`, + ) + } + // Log URL scheme execution (mask sensitive data) const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) => // Mask potential sensitive query parameters @@ -399,7 +443,7 @@ ${content} logger.info(`[URLScheme:${requestId}] Opening URL scheme`, { scheme: safeScheme, - protocol: scheme.split("://")[0], + protocol, }) // Use Electron's shell.openExternal to open URL scheme diff --git a/apps/desktop/layer/renderer/src/atoms/popover.ts b/apps/desktop/layer/renderer/src/atoms/popover.ts index 01138ee0c..edc3015b0 100644 --- a/apps/desktop/layer/renderer/src/atoms/popover.ts +++ b/apps/desktop/layer/renderer/src/atoms/popover.ts @@ -9,7 +9,7 @@ import { createAtomHooks, jotaiStore } from "~/lib/jotai" export interface PopoverProps extends Omit { /** Custom z-index for popover */ zIndex?: number - /** Whether the popover should close when clicked outside */ + /** Whether the popover should use modal focus and pointer behavior */ modal?: boolean } @@ -33,6 +33,11 @@ export const showPopover = ( element: ReactNode, props?: PopoverProps, ) => { + const currentPopover = jotaiStore.get(popoverAtom) + if (currentPopover.open) { + currentPopover.abortController.abort() + } + jotaiStore.set(popoverAtom, { open: true, position: mouseXY, @@ -41,3 +46,11 @@ export const showPopover = ( abortController: new AbortController(), }) } + +export const dismissPopover = () => { + const currentPopover = jotaiStore.get(popoverAtom) + if (!currentPopover.open) return + + currentPopover.abortController.abort() + jotaiStore.set(popoverAtom, { open: false }) +} diff --git a/apps/desktop/layer/renderer/src/components/common/SharePanel.test.tsx b/apps/desktop/layer/renderer/src/components/common/SharePanel.test.tsx new file mode 100644 index 000000000..0d458439b --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/common/SharePanel.test.tsx @@ -0,0 +1,153 @@ +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest" + +import { SharePanel } from "./SharePanel" + +const mocks = vi.hoisted(() => ({ + copyToClipboard: vi.fn(), + dismissPopover: vi.fn(), + getEntry: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock("@follow/store/entry/getter", () => ({ + getEntry: mocks.getEntry, +})) + +vi.mock("~/atoms/popover", () => ({ + dismissPopover: mocks.dismissPopover, +})) + +vi.mock("~/lib/client", () => ({ + ipcServices: undefined, +})) + +vi.mock("~/lib/clipboard", () => ({ + copyToClipboard: mocks.copyToClipboard, +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("sonner", () => ({ + toast: { + error: mocks.toastError, + success: mocks.toastSuccess, + }, +})) + +const waitForShareAction = async () => { + for (let index = 0; index < 2; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +const renderSharePanel = async () => { + const container = document.createElement("div") + document.body.append(container) + + const root = createRoot(container) + await act(async () => { + root.render() + }) + + return { container, root } +} + +const clickAction = async (container: HTMLElement, label: string) => { + const button = Array.from(container.querySelectorAll("button")).find((element) => + element.textContent?.includes(label), + ) + expect(button).not.toBeUndefined() + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) + await waitForShareAction() + }) +} + +describe("SharePanel", () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeAll(() => { + ;(globalThis as typeof globalThis & { React: typeof React }).React = React + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + }) + + beforeEach(() => { + mocks.getEntry.mockReturnValue({ + description: "Example description", + id: "entry-1", + title: "Example entry", + url: "https://example.com/article", + }) + mocks.copyToClipboard.mockResolvedValue(undefined) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + document.body.innerHTML = "" + root = null + container = null + Reflect.deleteProperty(navigator, "share") + vi.clearAllMocks() + }) + + test("dismisses after copying the link", async () => { + ;({ container, root } = await renderSharePanel()) + + await clickAction(container, "share.copy_link") + + expect(mocks.copyToClipboard).toHaveBeenCalledWith("https://example.com/article") + expect(mocks.toastSuccess).toHaveBeenCalledWith("share.link_copied") + expect(mocks.dismissPopover).toHaveBeenCalledOnce() + }) + + test("keeps the panel open when copying the link fails", async () => { + mocks.copyToClipboard.mockRejectedValueOnce(new Error("Clipboard unavailable")) + ;({ container, root } = await renderSharePanel()) + + await clickAction(container, "share.copy_link") + + expect(mocks.toastError).toHaveBeenCalledWith("share.copy_failed") + expect(mocks.dismissPopover).not.toHaveBeenCalled() + }) + + test("dismisses after system sharing completes", async () => { + const share = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "share", { + configurable: true, + value: share, + }) + ;({ container, root } = await renderSharePanel()) + + await clickAction(container, "share.system_share") + + expect(share).toHaveBeenCalledWith({ + text: "Example description | share.discover_more", + title: "Example entry - Folo", + url: "https://example.com/article", + }) + expect(mocks.dismissPopover).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx index 22b1e5512..40e95f47c 100644 --- a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx +++ b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx @@ -5,6 +5,7 @@ import { useCallback } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { dismissPopover } from "~/atoms/popover" import { ipcServices } from "~/lib/client" import { copyToClipboard } from "~/lib/clipboard" @@ -140,11 +141,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) } + dismissPopover() } catch { // If sharing fails, copy link as fallback try { await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) + dismissPopover() } catch { toast.error(t("share.copy_failed")) } @@ -156,6 +159,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { try { await copyToClipboard(shareUrl) toast.success(t("share.link_copied")) + dismissPopover() } catch { toast.error(t("share.copy_failed")) } @@ -178,6 +182,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { .replace("{text}", shareText) window.open(finalUrl, "_blank", "width=600,height=400") + dismissPopover() }, [entryId, generateShareContent], ) diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx index 58bd78603..61912b551 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx @@ -10,6 +10,7 @@ import { useMutation, useQuery } from "@tanstack/react-query" import type { TFunction } from "i18next" import { useState } from "react" import { useTranslation } from "react-i18next" +import { toast } from "sonner" import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs" import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs" @@ -17,6 +18,15 @@ import { followClient } from "~/lib/api-client" import { subscription } from "~/lib/auth" const APPLE_SUBSCRIPTION_MANAGEMENT_URL = "https://apps.apple.com/account/subscriptions" +const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS" + +type BillingPortalResponse = { + code: number + data?: { + url: string + } + message?: string +} type ActiveSubscription = { source: "stripe" | "apple" | null @@ -83,6 +93,29 @@ const formatFeatureValue = ( return value } +const openStripeBillingPortal = async () => { + const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href + const res = await fetch(`${env.VITE_API_URL}/billing/portal`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ returnUrl }), + }) + const data = (await res.json()) as BillingPortalResponse + if (!res.ok || data.code !== 0 || !data.data?.url) { + throw new Error(data.message || "Failed to open billing portal") + } + + if (IN_ELECTRON) { + window.open(data.data.url, "_blank") + return + } + + window.location.assign(data.data.url) +} + const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: boolean }) => { return useMutation({ mutationFn: async () => { @@ -97,10 +130,20 @@ const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: bo cancelUrl: env.VITE_WEB_URL, disableRedirect: IN_ELECTRON, }) + if (res.error?.code === ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE) { + await openStripeBillingPortal() + return + } + if (res.error) { + throw new Error(res.error.message) + } if (IN_ELECTRON && res.data?.url) { window.open(res.data.url, "_blank") } }, + onError: (error) => { + toast.error(error.message) + }, }) } @@ -120,20 +163,9 @@ const useActiveSubscription = () => { const useBillingPortal = () => { return useMutation({ - mutationFn: async () => { - const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href - const res = await fetch(`${env.VITE_API_URL}/billing/portal`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ returnUrl }), - }) - const data = await res.json() - if (data.code === 0 && data.data?.url) { - window.open(data.data.url, "_blank") - } + mutationFn: openStripeBillingPortal, + onError: (error) => { + toast.error(error.message) }, }) } diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx index 99a3b9a64..cf3dea6ef 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx @@ -323,7 +323,7 @@ function FeedCategoryImpl({ onClick={handleCollapseButtonClick} data-state={open ? "open" : "close"} className={cn( - "flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90", + "flex h-8 items-center data-[state=open]:[&_.i-mgc-right-cute-fi]:rotate-90", )} tabIndex={-1} > diff --git a/apps/desktop/layer/renderer/src/providers/popover-provider.test.tsx b/apps/desktop/layer/renderer/src/providers/popover-provider.test.tsx new file mode 100644 index 000000000..a802aee74 --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/popover-provider.test.tsx @@ -0,0 +1,147 @@ +import { GlobalFocusableProvider } from "@follow/components/common/Focusable/GlobalFocusableProvider.js" +import { Provider } from "jotai" +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest" + +import { dismissPopover, popoverAtom, showPopover } from "~/atoms/popover" +import { jotaiStore } from "~/lib/jotai" + +import { PopoverProvider } from "./popover-provider" + +const waitForPopoverEffects = async () => { + for (let index = 0; index < 3; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +const renderProvider = async () => { + const container = document.createElement("div") + document.body.append(container) + + const root = createRoot(container) + await act(async () => { + root.render( + + + +
App content
+
+
+
, + ) + }) + + return { container, root } +} + +describe("PopoverProvider", () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeAll(() => { + ;(globalThis as typeof globalThis & { React: typeof React }).React = React + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + vi.spyOn(console, "info").mockImplementation(() => {}) + + Object.assign(window, { + addEventListener: document.defaultView?.addEventListener.bind(document.defaultView), + clearTimeout, + Element: document.defaultView?.Element ?? Element, + getComputedStyle: + document.defaultView?.getComputedStyle.bind(document.defaultView) ?? getComputedStyle, + HTMLElement: document.defaultView?.HTMLElement ?? HTMLElement, + innerHeight: 768, + innerWidth: 1024, + Node: document.defaultView?.Node ?? Node, + removeEventListener: document.defaultView?.removeEventListener.bind(document.defaultView), + setTimeout, + }) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + afterEach(async () => { + await act(async () => { + jotaiStore.set(popoverAtom, { open: false }) + await waitForPopoverEffects() + }) + + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + document.body.innerHTML = "" + root = null + container = null + vi.clearAllMocks() + }) + + test("closes when clicking outside", async () => { + ;({ container, root } = await renderProvider()) + + await act(async () => { + showPopover({ x: 120, y: 80 },
Share content
) + await waitForPopoverEffects() + }) + + expect(document.body.textContent).toContain("Share content") + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + + const appContent = Array.from(container.querySelectorAll("div")).find( + (element) => element.textContent === "App content", + ) + expect(appContent).not.toBeUndefined() + + await act(async () => { + for (const eventType of ["pointerdown", "pointerup", "click"]) { + appContent?.dispatchEvent( + new PointerEvent(eventType, { + bubbles: true, + button: 0, + cancelable: true, + }), + ) + } + await waitForPopoverEffects() + }) + + expect(jotaiStore.get(popoverAtom).open).toBe(false) + expect(document.body.textContent).not.toContain("Share content") + }) + + test("can reopen after a programmatic dismissal", async () => { + ;({ container, root } = await renderProvider()) + + await act(async () => { + showPopover({ x: 120, y: 80 },
First popover
) + await waitForPopoverEffects() + }) + + expect(document.body.textContent).toContain("First popover") + + await act(async () => { + dismissPopover() + await waitForPopoverEffects() + }) + + expect(document.body.textContent).not.toContain("First popover") + + await act(async () => { + showPopover({ x: 140, y: 100 },
Second popover
) + await waitForPopoverEffects() + }) + + expect(jotaiStore.get(popoverAtom).open).toBe(true) + expect(document.body.textContent).toContain("Second popover") + }) +}) diff --git a/apps/desktop/layer/renderer/src/providers/popover-provider.tsx b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx index 057912265..8eb7d90a8 100644 --- a/apps/desktop/layer/renderer/src/providers/popover-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx @@ -8,9 +8,9 @@ import { PopoverTrigger, } from "@follow/components/ui/popover/index.jsx" import { AnimatePresence, m } from "motion/react" -import { memo, useEffect, useRef } from "react" +import { memo, useEffect } from "react" -import { usePopoverState } from "~/atoms/popover" +import { dismissPopover, usePopoverValue } from "~/atoms/popover" import { HotkeyScope } from "~/constants" export const PopoverProvider: Component = ({ children }) => ( @@ -21,36 +21,31 @@ export const PopoverProvider: Component = ({ children }) => ( ) const Handler = memo(() => { - const ref = useRef(null) - const [popoverState, setPopoverState] = usePopoverState() + const popoverState = usePopoverValue() const setGlobalFocusableScope = useSetGlobalFocusableScope() useEffect(() => { if (!popoverState.open) return - const triggerElement = ref.current - if (!triggerElement) return - triggerElement.dispatchEvent( - new MouseEvent("click", { - bubbles: true, - cancelable: true, - }), - ) - }, [popoverState]) + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append") + return () => { + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove") + } + }, [popoverState.open, setGlobalFocusableScope]) + + const { modal, zIndex, ...contentProps } = popoverState.open ? (popoverState.props ?? {}) : {} return ( { - if (state) { - setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append") - } else { - setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove") - setPopoverState({ open: false }) + if (!state) { + dismissPopover() } }} > { : {} } /> - - - {popoverState.open && ( + + {popoverState.open && ( + { {popoverState.content} - )} - - + + )} + ) }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0a2706089..eff667961 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "1.10.0", + "version": "1.11.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -99,7 +99,7 @@ "workbox-build": "7.4.1", "workbox-window": "7.4.1" }, - "runtimeVersion": "1.9.0", + "runtimeVersion": "1.11.0", "productName": "Folo", - "mainHash": "79b9a1c73ebea4fc92b28cc4952a2fe92557557141b1de0c81074622d8a1f404" + "mainHash": "b672f321b6478cbaad0ad6e354d73ca3c8a79f90aaadb41c119499639ec66e87" } diff --git a/apps/desktop/release.json b/apps/desktop/release.json index 53251c263..599812e85 100644 --- a/apps/desktop/release.json +++ b/apps/desktop/release.json @@ -1,6 +1,6 @@ { - "version": "1.10.0", - "mode": "ota", - "runtimeVersion": "1.9.0", - "channel": "stable" + "version": "1.11.0", + "mode": "build", + "runtimeVersion": null, + "channel": null } diff --git a/apps/desktop/scripts/merge-yml.ts b/apps/desktop/scripts/merge-yml.ts index f28562c8c..12d124382 100644 --- a/apps/desktop/scripts/merge-yml.ts +++ b/apps/desktop/scripts/merge-yml.ts @@ -1,6 +1,6 @@ import fs from "node:fs" -import yaml from "js-yaml" +import * as yaml from "js-yaml" import path from "pathe" const outDir = "./out/make" diff --git a/apps/desktop/scripts/update-windows-yml.ts b/apps/desktop/scripts/update-windows-yml.ts index 45afd2775..5c04c075c 100644 --- a/apps/desktop/scripts/update-windows-yml.ts +++ b/apps/desktop/scripts/update-windows-yml.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto" import fs from "node:fs" import { fileURLToPath, resolve } from "node:url" -import yaml from "js-yaml" +import * as yaml from "js-yaml" const __dirname = fileURLToPath(new URL(".", import.meta.url)) const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/") diff --git a/apps/mobile/changelog/0.5.7.md b/apps/mobile/changelog/0.5.7.md new file mode 100644 index 000000000..c233ed73d --- /dev/null +++ b/apps/mobile/changelog/0.5.7.md @@ -0,0 +1,12 @@ +# What's New in v0.5.7 + +## Improvements + +- Improved RSSHub subscription-limit errors with localized upgrade guidance and without internal request details + +## No longer broken + +- Fixed Apple subscription purchases and restores failing when product and transaction identifiers were confused +- Fixed upgrades for active or past-due Stripe subscribers by opening billing management +- Restored readable text colors in dark mode +- Fixed border styling in web-rendered content affected by a shared CSS token collision diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index 649773993..5508aad5f 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -33,7 +33,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.5.6 + 0.5.7 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 9 + 10 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/apps/mobile/package.json b/apps/mobile/package.json index be6faa61c..4c28bba1c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "0.5.6", + "version": "0.5.7", "private": true, "main": "src/main.tsx", "scripts": { @@ -135,7 +135,7 @@ "react-native-sheet-transitions": "0.1.2", "react-native-svg": "15.15.5", "react-native-track-player": "4.1.2", - "react-native-uikit-colors": "1.0.0", + "react-native-uikit-colors": "0.6.2", "react-native-volume-manager": "2.0.8", "react-native-web": "0.21.2", "react-native-webview": "14.0.1", diff --git a/apps/mobile/release.json b/apps/mobile/release.json index 6184b941a..ecfccfe72 100644 --- a/apps/mobile/release.json +++ b/apps/mobile/release.json @@ -1,5 +1,5 @@ { - "version": "0.5.6", + "version": "0.5.7", "mode": "store", "runtimeVersion": null, "channel": null diff --git a/apps/mobile/scripts/uikit-colors.test.ts b/apps/mobile/scripts/uikit-colors.test.ts new file mode 100644 index 000000000..a635d8531 --- /dev/null +++ b/apps/mobile/scripts/uikit-colors.test.ts @@ -0,0 +1,37 @@ +import postcss from "postcss" +import { cssToReactNativeRuntime } from "react-native-css-interop/css-to-rn" +import { withUIKit } from "react-native-uikit-colors/tailwind" +import tailwindcss from "tailwindcss" +import { describe, expect, it } from "vitest" + +const alphaColorClasses = [ + "border-separator", + "border-non-opaque-separator", + "bg-system-fill", + "bg-secondary-system-fill", + "bg-tertiary-system-fill", + "bg-quaternary-system-fill", + "text-secondary-label", + "text-tertiary-label", + "text-quaternary-label", +] + +describe("UIKit alpha colors", () => { + it("compiles semantic colors for the native runtime", async () => { + const config = withUIKit({ + content: [{ raw: alphaColorClasses.join(" ") }], + }) + const { css } = await postcss([tailwindcss(config)]).process("@tailwind utilities;", { + from: undefined, + }) + const compiled = cssToReactNativeRuntime(css) + + for (const className of alphaColorClasses) { + const ruleSet = compiled.rules?.[className] + const hasDeclarations = ruleSet?.n?.some((rule) => (rule.d?.length ?? 0) > 0) + + expect(ruleSet?.warnings, className).toBeUndefined() + expect(hasDeclarations, className).toBe(true) + } + }) +}) diff --git a/apps/mobile/src/lib/error-message.test.ts b/apps/mobile/src/lib/error-message.test.ts new file mode 100644 index 000000000..4068f2af0 --- /dev/null +++ b/apps/mobile/src/lib/error-message.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" + +import { sanitizeErrorMessage } from "./error-message" + +describe("sanitizeErrorMessage", () => { + it("removes Follow API request context from display messages", () => { + const message = [ + "RSSHub feed subscription limit exceeded", + "Request: POST /subscriptions (original: /subscriptions)", + "Args: {", + ' "headers": {', + ' "cookie": "session=secret"', + " }", + "}", + ].join("\n") + + expect(sanitizeErrorMessage(message)).toBe("RSSHub feed subscription limit exceeded") + }) + + it("supports CRLF request context", () => { + expect( + sanitizeErrorMessage("Subscription limit exceeded\r\nRequest: POST /subscriptions"), + ).toBe("Subscription limit exceeded") + }) + + it("preserves ordinary error messages", () => { + expect(sanitizeErrorMessage("Unable to follow this feed")).toBe("Unable to follow this feed") + }) +}) diff --git a/apps/mobile/src/lib/error-message.ts b/apps/mobile/src/lib/error-message.ts new file mode 100644 index 000000000..7e8544a5e --- /dev/null +++ b/apps/mobile/src/lib/error-message.ts @@ -0,0 +1,4 @@ +const FOLLOW_API_REQUEST_CONTEXT_PATTERN = /\r?\nRequest:[\s\S]*$/u + +export const sanitizeErrorMessage = (message: string) => + message.replace(FOLLOW_API_REQUEST_CONTEXT_PATTERN, "").trim() diff --git a/apps/mobile/src/lib/error-parser.test.ts b/apps/mobile/src/lib/error-parser.test.ts new file mode 100644 index 000000000..8d0796388 --- /dev/null +++ b/apps/mobile/src/lib/error-parser.test.ts @@ -0,0 +1,79 @@ +import { FollowAPIError } from "@follow-app/client-sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { toastFetchError } from "./error-parser" + +const mocks = vi.hoisted(() => ({ + isPaymentEnabled: false, + showUpgradeRequiredDialog: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock("i18next", () => ({ + t: (key: string) => { + if (key === "errors:2012") { + return "RSSHub feed subscription limit exceeded" + } + return key.replace(/^errors:/u, "") + }, +})) + +vi.mock("@/src/atoms/server-configs", () => ({ + getIsPaymentEnabled: () => mocks.isPaymentEnabled, +})) + +vi.mock("@/src/modules/dialogs/UpgradeRequiredDialog", () => ({ + showUpgradeRequiredDialog: mocks.showUpgradeRequiredDialog, +})) + +vi.mock("./toast", () => ({ + toast: { + error: mocks.toastError, + }, +})) + +describe("toastFetchError", () => { + beforeEach(() => { + mocks.isPaymentEnabled = false + mocks.showUpgradeRequiredDialog.mockClear() + mocks.toastError.mockClear() + }) + + it("shows a concise upgrade dialog for RSSHub subscription limits", () => { + mocks.isPaymentEnabled = true + const error = new FollowAPIError( + [ + "RSSHub feed subscription limit exceeded", + "Request: POST /subscriptions (original: /subscriptions)", + 'Args: { "headers": { "cookie": "session=secret" } }', + ].join("\n"), + 402, + "2012", + ) + + toastFetchError(error) + + expect(mocks.showUpgradeRequiredDialog).toHaveBeenCalledWith({ + title: "RSSHub feed subscription limit exceeded", + message: "settings:subscription.summary.free_description", + }) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it("does not expose request context when an API error code has no translation", () => { + const error = new FollowAPIError( + [ + "Unable to follow this feed", + "Request: POST /subscriptions (original: /subscriptions)", + 'Args: { "headers": { "cookie": "session=secret" } }', + ].join("\n"), + 400, + "29999", + ) + + toastFetchError(error) + + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(mocks.toastError).toHaveBeenCalledWith("Unable to follow this feed") + }) +}) diff --git a/apps/mobile/src/lib/error-parser.ts b/apps/mobile/src/lib/error-parser.ts index 76ec4968f..cfcea8b4e 100644 --- a/apps/mobile/src/lib/error-parser.ts +++ b/apps/mobile/src/lib/error-parser.ts @@ -5,6 +5,7 @@ import { FetchError } from "ofetch" import { getIsPaymentEnabled } from "@/src/atoms/server-configs" import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog" +import { sanitizeErrorMessage } from "./error-message" import { toast } from "./toast" export const getFetchErrorInfo = ( @@ -21,11 +22,11 @@ export const getFetchErrorInfo = ( const i18nKey = `errors:${code}` as any const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey) return { - message: `${i18nMessage}${reason ? `: ${reason}` : ""}`, + message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`), code, } } catch { - return { message: error.message } + return { message: sanitizeErrorMessage(error.message) } } } @@ -35,15 +36,15 @@ export const getFetchErrorInfo = ( const i18nKey = `errors:${code}` as any const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey) return { - message: i18nMessage, + message: sanitizeErrorMessage(i18nMessage), code, } } catch { - return { message: error.message } + return { message: sanitizeErrorMessage(error.message) } } } - return { message: error.message } + return { message: sanitizeErrorMessage(error.message) } } export const getFetchErrorMessage = (error: Error) => { @@ -58,7 +59,7 @@ export const createErrorToaster = (title?: string) => (err: Error) => toastFetchError(err, { title }) export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => { - const { message: fallbackMessage } = error + const fallbackMessage = sanitizeErrorMessage(error.message) let message = fallbackMessage let _reason = "" let code: number | undefined @@ -113,6 +114,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin } } + message = sanitizeErrorMessage(message) + // 2fa errors are handled by the form if (code === 4007 || code === 4008) { return diff --git a/apps/mobile/src/modules/settings/routes/Plan.tsx b/apps/mobile/src/modules/settings/routes/Plan.tsx index b6a2e8bf1..75fc3d13f 100644 --- a/apps/mobile/src/modules/settings/routes/Plan.tsx +++ b/apps/mobile/src/modules/settings/routes/Plan.tsx @@ -76,6 +76,7 @@ const PLAN_FEATURE_ORDER: Array = [ ] const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"] +const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS" type SegmentLayout = { width: number @@ -110,6 +111,14 @@ type ActiveSubscription = { canManage: boolean } +type BillingPortalResponse = { + code: number + data?: { + url: string + } + message?: string +} + const currencyFormatter = (() => { try { return new Intl.NumberFormat("en-US", { @@ -425,6 +434,25 @@ export const PlanScreen: NavigationControllerView = () => { return Math.round(total / paidPlans.length) }, [sortedPlans]) + const openStripeBillingPortal = useCallback(async () => { + const data = await followClient.request("/billing/portal", { + method: "POST", + body: { returnUrl: proxyEnv.WEB_URL }, + }) + if (data.code !== 0 || !data.data?.url) { + throw new Error(data.message || t("subscription.actions.manage_error")) + } + + await openURL(data.data.url) + }, [t]) + + const billingPortalMutation = useMutation({ + mutationFn: openStripeBillingPortal, + onError: () => { + toast.error(t("subscription.actions.manage_error")) + }, + }) + const upgradeMutation = useMutation({ mutationFn: async ({ planId, annual }) => { const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId) @@ -452,6 +480,13 @@ export const PlanScreen: NavigationControllerView = () => { cancelUrl: proxyEnv.WEB_URL, disableRedirect: true, }) + if (response.error?.code === ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE) { + await openStripeBillingPortal() + return + } + if (response.error) { + throw new Error(response.error.message) + } const redirectUrl = typeof response === "object" && response && "data" in response && response.data @@ -468,24 +503,6 @@ export const PlanScreen: NavigationControllerView = () => { }, }) - const billingPortalMutation = useMutation({ - mutationFn: async () => { - const data = await followClient.request<{ code: number; data?: { url: string } }>( - "/billing/portal", - { - method: "POST", - body: { returnUrl: proxyEnv.WEB_URL }, - }, - ) - if (data.code === 0 && data.data?.url) { - await openURL(data.data.url) - } - }, - onError: () => { - toast.error(t("subscription.actions.manage_error")) - }, - }) - const handleManageSubscription = useCallback(() => { billingPortalMutation.mutate() }, [billingPortalMutation]) diff --git a/apps/mobile/src/providers/AppleIAPProvider.tsx b/apps/mobile/src/providers/AppleIAPProvider.tsx index da6fdc213..5505d6b9d 100644 --- a/apps/mobile/src/providers/AppleIAPProvider.tsx +++ b/apps/mobile/src/providers/AppleIAPProvider.tsx @@ -14,6 +14,12 @@ import { proxyEnv } from "@/src/lib/proxy-env" import { queryClient } from "@/src/lib/query-client" import { toast } from "@/src/lib/toast" +import { + buildAppleVerificationRequest, + isKnownAppleSubscriptionPurchase, + selectSignedTransactionInfo, +} from "./apple-iap-purchase" + const billingSubscriptionQueryKey = ["billingSubscription"] type BillingSubscriptionResponse = { @@ -123,16 +129,23 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { const verifyPurchase = useCallback( async (purchase: Purchase) => { - const productId = purchase.id - const jwsRepresentation = - purchase.purchaseToken || - (await getTransactionJwsIOS(productId).catch(() => null)) || - (await validateReceipt({ apple: { sku: productId } }) - .then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined)) - .catch(() => {})) + const productId = purchase.productId + let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken) - if (!jwsRepresentation) { - throw new Error(t("subscription.actions.upgrade_error")) + if (!signedTransactionInfo) { + signedTransactionInfo = selectSignedTransactionInfo( + await getTransactionJwsIOS(productId).catch(() => null), + ) + } + + if (!signedTransactionInfo) { + signedTransactionInfo = selectSignedTransactionInfo( + await validateReceipt({ apple: { sku: productId } }) + .then((result) => + "jwsRepresentation" in result ? result.jwsRepresentation : undefined, + ) + .catch(() => undefined), + ) } const response = await followClient.request<{ @@ -140,23 +153,21 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { data: BillingSubscriptionResponse }>("/billing/apple/verify", { method: "POST", - body: { - signedTransactionInfo: jwsRepresentation, - }, + body: buildAppleVerificationRequest(purchase, signedTransactionInfo), }) if (response.code !== 0) { throw new Error("Failed to verify Apple subscription") } }, - [t, validateReceipt], + [validateReceipt], ) useEffect(() => { if ( Platform.OS !== "ios" || !currentPurchase || - !knownSubscriptionIds.has(currentPurchase.id) + !isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds) ) { return } @@ -280,7 +291,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { await new Promise((resolve) => setTimeout(resolve, 300)) const restoredPurchases = availablePurchasesRef.current.filter((purchase) => - knownSubscriptionIds.has(purchase.id), + isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds), ) if (restoredPurchases.length === 0) { diff --git a/apps/mobile/src/providers/apple-iap-purchase.test.ts b/apps/mobile/src/providers/apple-iap-purchase.test.ts new file mode 100644 index 000000000..d07f33fee --- /dev/null +++ b/apps/mobile/src/providers/apple-iap-purchase.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" + +import { + buildAppleVerificationRequest, + isCompactJws, + isKnownAppleSubscriptionPurchase, + selectSignedTransactionInfo, +} from "./apple-iap-purchase" + +describe("Apple IAP purchase identifiers", () => { + it("matches subscriptions by product ID instead of transaction ID", () => { + const knownSubscriptionIds = new Set(["is.follow.basic.monthly"]) + const purchase = { + id: "2000001234567890", + productId: "is.follow.basic.monthly", + } + + expect(isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds)).toBe(true) + }) + + it("builds verification hints from transaction identifiers", () => { + const request = buildAppleVerificationRequest( + { + id: "2000001234567890", + originalTransactionIdentifierIOS: "2000001000000000", + productId: "is.follow.basic.monthly", + transactionId: "2000001234567890", + }, + "header.payload.signature", + ) + + expect(request).toEqual({ + originalTransactionId: "2000001000000000", + signedTransactionInfo: "header.payload.signature", + transactionId: "2000001234567890", + }) + }) + + it("falls back to the purchase ID when transactionId is absent", () => { + expect( + buildAppleVerificationRequest({ + id: "2000001234567890", + productId: "is.follow.basic.monthly", + }), + ).toEqual({ + originalTransactionId: undefined, + signedTransactionInfo: undefined, + transactionId: "2000001234567890", + }) + }) + + it("does not submit a transaction ID as signed transaction info", () => { + expect(isCompactJws("2000001234567890")).toBe(false) + expect(selectSignedTransactionInfo("2000001234567890", null, "header.payload.signature")).toBe( + "header.payload.signature", + ) + expect( + buildAppleVerificationRequest( + { + id: "2000001234567890", + productId: "is.follow.basic.monthly", + purchaseToken: "2000001234567890", + }, + "2000001234567890", + ), + ).toEqual({ + originalTransactionId: undefined, + signedTransactionInfo: undefined, + transactionId: "2000001234567890", + }) + }) +}) diff --git a/apps/mobile/src/providers/apple-iap-purchase.ts b/apps/mobile/src/providers/apple-iap-purchase.ts new file mode 100644 index 000000000..6b498a9a5 --- /dev/null +++ b/apps/mobile/src/providers/apple-iap-purchase.ts @@ -0,0 +1,44 @@ +export type ApplePurchaseIdentity = { + id: string + originalTransactionIdentifierIOS?: string | null + productId: string + purchaseToken?: string | null + transactionId?: string | null +} + +export type AppleVerificationRequest = { + originalTransactionId?: string + signedTransactionInfo?: string + transactionId?: string +} + +const compactJwsSegmentPattern = /^[\w-]+$/ + +export const isCompactJws = (value?: string | null): value is string => { + if (!value) { + return false + } + + const segments = value.split(".") + return ( + segments.length === 3 && + segments.every((segment) => segment.length > 0 && compactJwsSegmentPattern.test(segment)) + ) +} + +export const selectSignedTransactionInfo = (...candidates: Array) => + candidates.find(isCompactJws) + +export const isKnownAppleSubscriptionPurchase = ( + purchase: Pick, + knownSubscriptionIds: ReadonlySet, +) => knownSubscriptionIds.has(purchase.productId) + +export const buildAppleVerificationRequest = ( + purchase: ApplePurchaseIdentity, + signedTransactionInfo?: string | null, +): AppleVerificationRequest => ({ + originalTransactionId: purchase.originalTransactionIdentifierIOS || undefined, + signedTransactionInfo: isCompactJws(signedTransactionInfo) ? signedTransactionInfo : undefined, + transactionId: purchase.transactionId || purchase.id || undefined, +}) diff --git a/apps/ssr/client/components/common/404.tsx b/apps/ssr/client/components/common/404.tsx index 607916320..1b356ceec 100644 --- a/apps/ssr/client/components/common/404.tsx +++ b/apps/ssr/client/components/common/404.tsx @@ -7,7 +7,7 @@ import { m, useAnimationControls } from "motion/react" import { Fragment, useEffect, useState } from "react" import * as React from "react" -const NotFoundContent = () => { +export const NotFoundContent = () => { const [glitchText, setGlitchText] = useState("404") const [isGlitching, setIsGlitching] = useState(false) diff --git a/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx b/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx index cbb3ef9fc..2698ed3cf 100644 --- a/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx +++ b/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx @@ -1,3 +1,4 @@ +import { NotFoundContent } from "@client/components/common/404" import { FeedIcon } from "@client/components/ui/feed-icon" import { openInFollowApp } from "@client/lib/helper" import { UrlBuilder } from "@client/lib/url-builder" @@ -10,6 +11,7 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx" import { useTitle } from "@follow/hooks" import { cn } from "@follow/utils/utils" import type { SubscriptionWithFeed, UserProfile } from "@follow-app/client-sdk" +import { FollowAPIError } from "@follow-app/client-sdk" import * as React from "react" import { Fragment, memo, useState } from "react" import { useParams } from "react-router" @@ -89,22 +91,43 @@ export const Component = () => { useTitle(user.data?.name) + if (user.isLoading) { + return + } + + if (!user.data) { + if (user.error instanceof FollowAPIError && user.error.status === 404) { + return + } + + return void user.refetch()} /> + } + return ( - <> - {user.isLoading ? ( - - ) : ( - - - - {/* Subscriptions Section */} - - - )} - + + + + {/* Subscriptions Section */} + + ) } +const ProfileLoadError = ({ onRetry }: { onRetry: () => void }) => ( +
+ +

+ Unable to load this profile +

+

+ This may be a temporary problem. Please try again. +

+ +
+) + const UserHero = ({ user }: { user: UserProfile }) => { const subscriptions = useUserSubscriptionsQuery(user.id) diff --git a/apps/ssr/client/query/users.ts b/apps/ssr/client/query/users.ts index f351e4cb8..a3f0318cb 100644 --- a/apps/ssr/client/query/users.ts +++ b/apps/ssr/client/query/users.ts @@ -2,7 +2,7 @@ import { followClient } from "@client/lib/api-fetch" import { getProviders } from "@client/lib/auth" import { getHydrateData } from "@client/lib/helper" import type { LoginHydrateData } from "@client/pages/(login)/login/metadata" -import { isBizId, sortByAlphabet } from "@follow/utils/utils" +import { sortByAlphabet } from "@follow/utils/utils" import type { InboxSubscriptionResponse, ListSubscriptionResponse, @@ -10,6 +10,8 @@ import type { } from "@follow-app/client-sdk" import { useQuery } from "@tanstack/react-query" +import { getUserProfile } from "../../src/lib/user-profile-params" + type GetUserSubscriptionsResponse = ( SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse )[] @@ -69,13 +71,7 @@ export const useUserSubscriptionsQuery = (userId: string | undefined) => { } export const fetchUser = async (handleOrId: string | undefined) => { - const handle = isBizId(handleOrId || "") - ? handleOrId - : `${handleOrId}`.startsWith("@") - ? `${handleOrId}`.slice(1) - : handleOrId - - const res = await followClient.api.profiles.getProfile({ id: handleOrId, handle }) + const res = await getUserProfile(followClient, handleOrId) return res.data } diff --git a/apps/ssr/package.json b/apps/ssr/package.json index 87ae4d16b..8628f10d9 100644 --- a/apps/ssr/package.json +++ b/apps/ssr/package.json @@ -10,6 +10,7 @@ "dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts", "meta": "tsx helper/meta-map.ts --watch", "start": "tsx index.ts", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/ssr/src/lib/hydration-script.test.ts b/apps/ssr/src/lib/hydration-script.test.ts new file mode 100644 index 000000000..a270043ef --- /dev/null +++ b/apps/ssr/src/lib/hydration-script.test.ts @@ -0,0 +1,109 @@ +import { runInNewContext } from "node:vm" + +import { minify } from "html-minifier-terser" +import { parseHTML } from "linkedom" +import { describe, expect, it } from "vitest" + +import { createHydrationScript, injectHydrationScript } from "./hydration-script" + +describe("hydration script", () => { + it("keeps attacker-controlled keys and data inside a single script element", () => { + const key = `profile