Compare commits

..

No commits in common. "dev" and "desktop@1.11.0" have entirely different histories.

75 changed files with 198 additions and 1606 deletions

View File

@ -1,37 +0,0 @@
# 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
'''

View File

@ -7,21 +7,13 @@
import { execSync } from "node:child_process"
import { appendFileSync } from "node:fs"
import { pathToFileURL } from "node:url"
// Configuration
const RELEASE_PATTERNS = {
desktop: /^release\(desktop\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/i,
mobile: /^release\(mobile\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/i,
desktop: /release\(desktop\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i,
mobile: /release\(mobile\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i,
}
const RELEASE_PLATFORM_BY_REF = {
main: "desktop",
"mobile-main": "mobile",
}
const GITHUB_MERGE_SUBJECT_PATTERN = /^Merge pull request #\d+ from /i
const EXIT_CODES = {
SUCCESS: 0,
GIT_ERROR: 2,
@ -65,6 +57,7 @@ function setGitHubOutput(key, value) {
/**
* Get the latest commit message
* @returns {string} Latest commit message
*/
function getLatestCommitMessage() {
try {
@ -76,45 +69,21 @@ function getLatestCommitMessage() {
}
/**
* Extract release information from a commit message.
* Prefer the subject. For a standard GitHub merge commit, fall back only to the first non-empty body
* line, where GitHub stores the PR title. When a GitHub ref is available, only the platform released
* from that branch is considered. Other body lines are ignored so stale release commits cannot
* retrigger a release.
* Extract release information from commit message
* @param {string} commitMessage - Git commit message
* @param {string|undefined} refName - GitHub ref name
* @returns {{platform: string, version: string, tagName: string}|null} Release information or null
* @returns {Object|null} Release information or null if no release found
*/
export function extractReleaseInfo(commitMessage, refName = process.env.GITHUB_REF_NAME) {
const [commitSubject = "", ...commitBodyLines] = commitMessage.split(/\r?\n/)
const expectedPlatform = refName ? RELEASE_PLATFORM_BY_REF[refName] : undefined
function extractReleaseInfo(commitMessage) {
for (const [platform, regex] of Object.entries(RELEASE_PATTERNS)) {
const match = commitMessage.match(regex)
if (match) {
const version = match[1]
const tagName = `${platform}/${version}`
if (refName && !expectedPlatform) {
return null
}
const platforms = expectedPlatform ? [expectedPlatform] : Object.keys(RELEASE_PATTERNS)
const candidates = [commitSubject.trim()]
if (GITHUB_MERGE_SUBJECT_PATTERN.test(commitSubject)) {
const pullRequestTitle = commitBodyLines.map((line) => line.trim()).find(Boolean)
if (pullRequestTitle) {
candidates.push(pullRequestTitle)
}
}
for (const candidate of candidates) {
for (const platform of platforms) {
const match = candidate.match(RELEASE_PATTERNS[platform])
if (match) {
const version = match[1]
const tagName = `${platform}/${version}`
return {
platform,
version,
tagName,
}
return {
platform,
version,
tagName,
}
}
}
@ -159,6 +128,4 @@ function main() {
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
main()

View File

@ -1,161 +0,0 @@
import { execFile } from "node:child_process"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import { join } from "pathe"
import { describe, expect, it } from "vitest"
import { extractReleaseInfo } from "./extract-release-info.mjs"
const execFileAsync = promisify(execFile)
describe("extractReleaseInfo", () => {
it("recognizes a mobile release from the subject and ignores old desktop markers in the body", () => {
const commitMessage = [
"release(mobile): Release v0.5.7 (#5061)",
"",
"* release(desktop): release v1.11.0",
"* docs(mobile): restore desktop release inputs",
].join("\n")
expect(extractReleaseInfo(commitMessage, "mobile-main")).toEqual({
platform: "mobile",
version: "v0.5.7",
tagName: "mobile/v0.5.7",
})
})
it("rejects a release subject on the wrong target branch", () => {
expect(extractReleaseInfo("release(mobile): Release v0.5.7", "main")).toBeNull()
expect(extractReleaseInfo("release(desktop): Release v1.12.0", "mobile-main")).toBeNull()
})
it("recognizes the target platform release from a standard merge commit body", () => {
const commitMessage = [
"Merge pull request #5061 from RSSNext/release/mobile/0.5.7",
"",
"release(mobile): Release v0.5.7",
].join("\n")
expect(extractReleaseInfo(commitMessage, "mobile-main")).toEqual({
platform: "mobile",
version: "v0.5.7",
tagName: "mobile/v0.5.7",
})
})
it("ignores ordinary commits", () => {
expect(extractReleaseInfo("fix(mobile): restore release metadata", "mobile-main")).toBeNull()
})
it("ignores release markers for the wrong platform in a merge commit body", () => {
const commitMessage = [
"Merge pull request #5061 from RSSNext/release/mobile/0.5.7",
"",
"release(mobile): Release v0.5.7",
].join("\n")
expect(extractReleaseInfo(commitMessage, "main")).toBeNull()
})
it("ignores stale release markers later in an ordinary commit body", () => {
const commitMessage = [
"chore(sync): merge mobile-main into dev",
"",
"* release(mobile): Release v0.5.6",
].join("\n")
expect(extractReleaseInfo(commitMessage, "mobile-main")).toBeNull()
})
it("does not use a stale body marker when another platform release is the subject", () => {
const commitMessage = [
"release(desktop): Release v1.6.0",
"",
"* release(mobile): Release v0.4.1",
].join("\n")
expect(extractReleaseInfo(commitMessage, "mobile-main")).toBeNull()
})
it("recognizes a desktop release on main", () => {
expect(extractReleaseInfo("release(desktop): Release v1.12.0", "main")).toEqual({
platform: "desktop",
version: "v1.12.0",
tagName: "desktop/v1.12.0",
})
})
it("falls back to subject-based platform detection without a GitHub ref", () => {
const originalRefName = process.env.GITHUB_REF_NAME
delete process.env.GITHUB_REF_NAME
try {
expect(extractReleaseInfo("release(mobile): Release v0.5.7")).toEqual({
platform: "mobile",
version: "v0.5.7",
tagName: "mobile/v0.5.7",
})
} finally {
if (originalRefName === undefined) {
delete process.env.GITHUB_REF_NAME
} else {
process.env.GITHUB_REF_NAME = originalRefName
}
}
})
it("writes the existing GitHub environment and output values from the latest subject", async () => {
const repositoryDir = await mkdtemp(join(tmpdir(), "extract-release-info-"))
try {
const githubEnvPath = join(repositoryDir, "github-env.txt")
const githubOutputPath = join(repositoryDir, "github-output.txt")
const scriptPath = fileURLToPath(new URL("./extract-release-info.mjs", import.meta.url))
await execFileAsync("git", ["init"], { cwd: repositoryDir })
await execFileAsync("git", ["config", "user.name", "Release Test"], {
cwd: repositoryDir,
})
await execFileAsync("git", ["config", "user.email", "release-test@example.com"], {
cwd: repositoryDir,
})
await execFileAsync(
"git",
[
"commit",
"--allow-empty",
"-m",
"release(mobile): Release v0.5.7 (#5061)",
"-m",
"* release(desktop): release v1.11.0",
],
{ cwd: repositoryDir },
)
await execFileAsync("node", [scriptPath], {
cwd: repositoryDir,
env: {
...process.env,
GITHUB_ENV: githubEnvPath,
GITHUB_OUTPUT: githubOutputPath,
GITHUB_REF_NAME: "mobile-main",
},
})
const githubEnv = await readFile(githubEnvPath, "utf8")
const githubOutput = await readFile(githubOutputPath, "utf8")
expect(githubEnv).toContain("tag_version=mobile/v0.5.7")
expect(githubEnv).toContain("platform=mobile")
expect(githubEnv).toContain("version=v0.5.7")
expect(githubOutput).toContain("tag_version=mobile/v0.5.7")
expect(githubOutput).toContain("platform=mobile")
expect(githubOutput).toContain("version=v0.5.7")
} finally {
await rm(repositoryDir, { recursive: true, force: true })
}
})
})

View File

@ -57,7 +57,7 @@ jobs:
uses: pnpm/action-setup@v6
- name: 🏗 Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"

View File

@ -83,7 +83,7 @@ jobs:
uses: pnpm/action-setup@v6
- name: Use Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"
@ -407,7 +407,7 @@ jobs:
uses: pnpm/action-setup@v6
- name: Use Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"

View File

@ -97,7 +97,7 @@ jobs:
uses: pnpm/action-setup@v6
- name: 🏗 Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"
@ -148,7 +148,7 @@ jobs:
uses: pnpm/action-setup@v6
- name: 🏗 Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"

View File

@ -117,7 +117,7 @@ jobs:
uses: pnpm/action-setup@v6
- name: 🏗 Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"

View File

@ -34,7 +34,7 @@ jobs:
- uses: pnpm/action-setup@v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"

View File

@ -35,7 +35,7 @@ jobs:
- uses: pnpm/action-setup@v6
- name: Use Node.js LTS
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: "pnpm"

View File

@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@v6
- name: Use Node.js LTS
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: "pnpm"

View File

@ -42,7 +42,7 @@ jobs:
- uses: pnpm/action-setup@v6
- name: Use Node.js LTS
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: "pnpm"

View File

@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
@ -52,10 +52,5 @@ jobs:
run: |
export NODE_OPTIONS="--max_old_space_size=16384"
npm exec turbo run format:check typecheck lint
- name: Run release workflow tests
run: >-
pnpm exec vitest run
.github/scripts/extract-release-info.test.ts
.github/scripts/release-workflow-guards.test.ts
- name: Run test
run: npm exec turbo run test

View File

@ -60,7 +60,7 @@ jobs:
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 22
cache: "pnpm"

View File

@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: lts/*
@ -36,6 +36,7 @@ jobs:
- name: Extract release information
id: extract_info
run: .github/scripts/extract-release-info.mjs
continue-on-error: true
- name: Expose release outputs
id: release_info

7
.gitignore vendored
View File

@ -37,11 +37,8 @@ apps/desktop/resources/cli
.wrangler
# Local agent artifacts, except the shared Codex environment
.codex/*
!.codex/environments/
.codex/environments/*
!.codex/environments/environment.toml
# Local agent artifacts
.codex/
# E2E outputs
/apps/desktop/e2e/playwright-report/

View File

@ -1,2 +0,0 @@
# Files copied by Codex into managed worktrees
apps/desktop/.env

View File

@ -1,17 +0,0 @@
# What's new in v1.12.0
## Improvements
- Hardened custom URL integrations so only trusted protocols can be opened
- Improved the error shown when an RSSHub subscription limit is reached
## No longer broken
- Fixed share popovers remaining open after completing an action
- Fixed plan upgrades for past-due Stripe subscriptions
- Restored category chevron rotation
- Restored correct border styling across the app
## Thanks
Special thanks to volunteer contributor @sebastionoss for hardening custom URL integrations

View File

@ -36,43 +36,7 @@ const ymlMapsMap = {
win32: "latest.yml",
}
// 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 keepModules = new Set(["font-list", "vscode-languagedetection"])
const keepLanguages = new Set(["en", "en_GB", "en-US", "en_US"])
// remove folders & files not to be included in the app
@ -129,10 +93,7 @@ async function cleanSources(buildPath, _electronVersion, platform, _arch, callba
const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback()
const keepModulePattern = [...keepModules]
.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|")
const ignorePattern = new RegExp(`^/node_modules/(?!(?:${keepModulePattern})(?:/|$))`)
const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`)
const config: ForgeConfig = {
packagerConfig: {

View File

@ -1,9 +1,8 @@
import fsp from "node:fs/promises"
import os from "node:os"
import { shell } from "electron"
import path from "pathe"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { afterEach, describe, expect, it, vi } from "vitest"
import { IntegrationService } from "./integration"
@ -70,70 +69,4 @@ 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,<script>alert(1)</script>"],
["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)
})
})
})

View File

@ -81,29 +81,6 @@ export async function saveMediaToEagle(input: SaveToEagleInput): Promise<any> {
}
}
// 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<string>([
"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"
@ -405,32 +382,11 @@ ${content}
const requestId = Math.random().toString(36).slice(2, 8)
try {
// 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 {
// Validate URL scheme format
if (!scheme.includes("://")) {
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
@ -443,7 +399,7 @@ ${content}
logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {
scheme: safeScheme,
protocol,
protocol: scheme.split("://")[0],
})
// Use Electron's shell.openExternal to open URL scheme

View File

@ -9,7 +9,7 @@ import { createAtomHooks, jotaiStore } from "~/lib/jotai"
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
/** Custom z-index for popover */
zIndex?: number
/** Whether the popover should use modal focus and pointer behavior */
/** Whether the popover should close when clicked outside */
modal?: boolean
}
@ -33,11 +33,6 @@ 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,
@ -46,11 +41,3 @@ 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 })
}

View File

@ -1,153 +0,0 @@
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(<SharePanel entryId="entry-1" />)
})
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()
})
})

View File

@ -5,7 +5,6 @@ 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"
@ -141,13 +140,11 @@ 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"))
}
@ -159,7 +156,6 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
try {
await copyToClipboard(shareUrl)
toast.success(t("share.link_copied"))
dismissPopover()
} catch {
toast.error(t("share.copy_failed"))
}
@ -182,7 +178,6 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
.replace("{text}", shareText)
window.open(finalUrl, "_blank", "width=600,height=400")
dismissPopover()
},
[entryId, generateShareContent],
)

View File

@ -10,7 +10,6 @@ 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"
@ -18,15 +17,6 @@ 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
@ -93,29 +83,6 @@ 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 () => {
@ -130,20 +97,10 @@ 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)
},
})
}
@ -163,9 +120,20 @@ const useActiveSubscription = () => {
const useBillingPortal = () => {
return useMutation({
mutationFn: openStripeBillingPortal,
onError: (error) => {
toast.error(error.message)
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")
}
},
})
}

View File

@ -323,7 +323,7 @@ function FeedCategoryImpl({
onClick={handleCollapseButtonClick}
data-state={open ? "open" : "close"}
className={cn(
"flex h-8 items-center data-[state=open]:[&_.i-mgc-right-cute-fi]:rotate-90",
"flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90",
)}
tabIndex={-1}
>

View File

@ -1,147 +0,0 @@
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(
<Provider store={jotaiStore}>
<GlobalFocusableProvider>
<PopoverProvider>
<div>App content</div>
</PopoverProvider>
</GlobalFocusableProvider>
</Provider>,
)
})
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 }, <div>Share content</div>)
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 }, <div>First popover</div>)
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 }, <div>Second popover</div>)
await waitForPopoverEffects()
})
expect(jotaiStore.get(popoverAtom).open).toBe(true)
expect(document.body.textContent).toContain("Second popover")
})
})

View File

@ -8,9 +8,9 @@ import {
PopoverTrigger,
} from "@follow/components/ui/popover/index.jsx"
import { AnimatePresence, m } from "motion/react"
import { memo, useEffect } from "react"
import { memo, useEffect, useRef } from "react"
import { dismissPopover, usePopoverValue } from "~/atoms/popover"
import { usePopoverState } from "~/atoms/popover"
import { HotkeyScope } from "~/constants"
export const PopoverProvider: Component = ({ children }) => (
@ -21,31 +21,36 @@ export const PopoverProvider: Component = ({ children }) => (
)
const Handler = memo(() => {
const popoverState = usePopoverValue()
const ref = useRef<HTMLButtonElement>(null)
const [popoverState, setPopoverState] = usePopoverState()
const setGlobalFocusableScope = useSetGlobalFocusableScope()
useEffect(() => {
if (!popoverState.open) return
const triggerElement = ref.current
if (!triggerElement) return
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
return () => {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
}
}, [popoverState.open, setGlobalFocusableScope])
const { modal, zIndex, ...contentProps } = popoverState.open ? (popoverState.props ?? {}) : {}
triggerElement.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
cancelable: true,
}),
)
}, [popoverState])
return (
<Popover
open={popoverState.open}
modal={modal}
onOpenChange={(state) => {
if (!state) {
dismissPopover()
if (state) {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
} else {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
setPopoverState({ open: false })
}
}}
>
<PopoverTrigger
ref={ref}
className="pointer-events-none"
style={
popoverState.open
@ -53,14 +58,9 @@ const Handler = memo(() => {
: {}
}
/>
<AnimatePresence>
{popoverState.open && (
<PopoverContent
{...contentProps}
asChild
forceMount
style={{ ...contentProps.style, zIndex }}
>
<PopoverContent asChild forceMount>
<AnimatePresence>
{popoverState.open && (
<m.div
className="mr-2 rounded-xl border bg-material-ultra-thick p-2 shadow-2xl backdrop-blur-background"
initial={{ opacity: 0, scale: 0.95, y: -10 }}
@ -71,9 +71,9 @@ const Handler = memo(() => {
<PopoverArrow className="fill-border" />
{popoverState.content}
</m.div>
</PopoverContent>
)}
</AnimatePresence>
)}
</AnimatePresence>
</PopoverContent>
</Popover>
)
})

View File

@ -1,7 +1,7 @@
{
"name": "Folo",
"type": "module",
"version": "1.12.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.12.0",
"runtimeVersion": "1.11.0",
"productName": "Folo",
"mainHash": "0fdc4db0cd19e2c2d6cf6c596aa1a62cc253b4c9612633470cb6b8d64fba403a"
"mainHash": "b672f321b6478cbaad0ad6e354d73ca3c8a79f90aaadb41c119499639ec66e87"
}

View File

@ -1,5 +1,5 @@
{
"version": "1.12.0",
"version": "1.11.0",
"mode": "build",
"runtimeVersion": null,
"channel": null

View File

@ -1,6 +1,6 @@
import fs from "node:fs"
import * as yaml from "js-yaml"
import yaml from "js-yaml"
import path from "pathe"
const outDir = "./out/make"

View File

@ -2,7 +2,7 @@ import crypto from "node:crypto"
import fs from "node:fs"
import { fileURLToPath, resolve } from "node:url"
import * as yaml from "js-yaml"
import yaml from "js-yaml"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/")

View File

@ -111,17 +111,6 @@ export default ({ config }: ConfigContext): ExpoConfig => {
},
android: {
package: "is.follow",
// Media selection uses system pickers; saving only needs write access on older Android versions.
blockedPermissions: [
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_AUDIO",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.RECORD_AUDIO",
],
adaptiveIcon: {
foregroundImage: adaptiveIconPath,
monochromeImage: adaptiveIconPath,
@ -169,8 +158,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
{
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
isAccessMediaLocationEnabled: false,
granularPermissions: [],
isAccessMediaLocationEnabled: true,
},
],
"expo-apple-authentication",
@ -204,8 +192,6 @@ export default ({ config }: ConfigContext): ExpoConfig => {
"expo-image-picker",
{
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
cameraPermission: false,
microphonePermission: false,
},
],
[

View File

@ -1,14 +0,0 @@
# What's New in v0.5.6
## Improvements
- Upgraded the app to Expo SDK 57, React Native 0.86, and updated native integrations
## No longer broken
- Fixed social sign-in callbacks and legacy session migration using the Folo app scheme
- Fixed signed-out launches not opening the login screen
- Fixed two-factor authentication cookies being dropped during session updates
- Fixed push notification registration after sign-in, including retries and token refreshes
- Fixed the timeline view selector overflowing on narrow screens
- Restored the header background after scrolling

View File

@ -1,12 +0,0 @@
# 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

View File

@ -1,5 +0,0 @@
# What's New in v0.5.8
## Improvements
- Updated Android photo selection and saving to use system pickers and request only the permissions required

View File

@ -1,11 +1,14 @@
# What's New in vNEXT_VERSION
## Shiny new things
## Improvements
- Upgraded the app to Expo SDK 57, React Native 0.86, and updated native integrations
## No longer broken
## Thanks
Special thanks to volunteer contributors @ for their valuable contributions
- Fixed social sign-in callbacks and legacy session migration using the Folo app scheme
- Fixed signed-out launches not opening the login screen
- Fixed two-factor authentication cookies being dropped during session updates
- Fixed push notification registration after sign-in, including retries and token refreshes
- Fixed the timeline view selector overflowing on narrow screens
- Restored the header background after scrolling

View File

@ -33,7 +33,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.5.8</string>
<string>0.5.5</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -54,7 +54,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>11</string>
<string>8</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>

View File

@ -1,6 +1,6 @@
{
"name": "@follow/mobile",
"version": "0.5.8",
"version": "0.5.5",
"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": "0.6.2",
"react-native-uikit-colors": "1.0.0",
"react-native-volume-manager": "2.0.8",
"react-native-web": "0.21.2",
"react-native-webview": "14.0.1",

View File

@ -1,6 +1,6 @@
{
"version": "0.5.8",
"mode": "store",
"runtimeVersion": null,
"channel": null
"version": "0.5.5",
"mode": "ota",
"runtimeVersion": "0.5.0",
"channel": "production"
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import createExpoConfig, { resolveRuntimeVersion } from "../app.config.base"
import { resolveRuntimeVersion } from "../app.config.base"
describe("resolveRuntimeVersion", () => {
it("keeps the development runtime version stable", () => {
@ -33,42 +33,3 @@ describe("resolveRuntimeVersion", () => {
).toThrow(/OTA_RUNTIME_VERSION/i)
})
})
describe("Android media permissions", () => {
const config = createExpoConfig({
config: {},
} as Parameters<typeof createExpoConfig>[0])
it("blocks broad media access that is not required by the app", () => {
expect(config.android?.blockedPermissions).toEqual([
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_AUDIO",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.RECORD_AUDIO",
])
})
it("configures media APIs for picker and write-only access", () => {
expect(config.plugins).toContainEqual([
"expo-media-library",
{
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
isAccessMediaLocationEnabled: false,
granularPermissions: [],
},
])
expect(config.plugins).toContainEqual([
"expo-image-picker",
{
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
cameraPermission: false,
microphonePermission: false,
},
])
})
})

View File

@ -1,37 +0,0 @@
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)
}
})
})

View File

@ -3,7 +3,7 @@ import { IMAGE_PROXY_URL } from "@follow/utils/img-proxy"
import ImageEditor from "@react-native-community/image-editor"
import * as FileSystem from "expo-file-system/legacy"
import type { ImageProps, ImageSource } from "expo-image"
import { Asset, usePermissions } from "expo-media-library"
import { saveToLibraryAsync, usePermissions } from "expo-media-library"
import * as Sharing from "expo-sharing"
import { useCallback } from "react"
import { Image } from "react-native"
@ -145,7 +145,7 @@ export const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => {
const croppedImage = await getImageData(uri)
const filename = `${extractFilenameFromUrl(uri)}.png`
const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename)
await Asset.create(filePath)
await saveToLibraryAsync(filePath)
cleanup()
}
@ -161,7 +161,7 @@ export const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => {
*/
export function useSaveImageToMediaLibrary() {
const [permissionResponse, requestPermission, getPermission] = usePermissions({
writeOnly: true,
granularPermissions: ["photo"],
})
return useCallback(
async (uri: string) => {

View File

@ -1,29 +0,0 @@
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")
})
})

View File

@ -1,4 +0,0 @@
const FOLLOW_API_REQUEST_CONTEXT_PATTERN = /\r?\nRequest:[\s\S]*$/u
export const sanitizeErrorMessage = (message: string) =>
message.replace(FOLLOW_API_REQUEST_CONTEXT_PATTERN, "").trim()

View File

@ -1,79 +0,0 @@
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")
})
})

View File

@ -5,7 +5,6 @@ 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 = (
@ -22,11 +21,11 @@ export const getFetchErrorInfo = (
const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
return {
message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`),
message: `${i18nMessage}${reason ? `: ${reason}` : ""}`,
code,
}
} catch {
return { message: sanitizeErrorMessage(error.message) }
return { message: error.message }
}
}
@ -36,15 +35,15 @@ export const getFetchErrorInfo = (
const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
return {
message: sanitizeErrorMessage(i18nMessage),
message: i18nMessage,
code,
}
} catch {
return { message: sanitizeErrorMessage(error.message) }
return { message: error.message }
}
}
return { message: sanitizeErrorMessage(error.message) }
return { message: error.message }
}
export const getFetchErrorMessage = (error: Error) => {
@ -59,7 +58,7 @@ export const createErrorToaster = (title?: string) => (err: Error) =>
toastFetchError(err, { title })
export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {
const fallbackMessage = sanitizeErrorMessage(error.message)
const { message: fallbackMessage } = error
let message = fallbackMessage
let _reason = ""
let code: number | undefined
@ -114,8 +113,6 @@ 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

View File

@ -76,7 +76,6 @@ const PLAN_FEATURE_ORDER: Array<keyof PaymentFeature> = [
]
const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"]
const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS"
type SegmentLayout = {
width: number
@ -111,14 +110,6 @@ type ActiveSubscription = {
canManage: boolean
}
type BillingPortalResponse = {
code: number
data?: {
url: string
}
message?: string
}
const currencyFormatter = (() => {
try {
return new Intl.NumberFormat("en-US", {
@ -434,25 +425,6 @@ export const PlanScreen: NavigationControllerView = () => {
return Math.round(total / paidPlans.length)
}, [sortedPlans])
const openStripeBillingPortal = useCallback(async () => {
const data = await followClient.request<BillingPortalResponse>("/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<void, Error, UpgradeVariables>({
mutationFn: async ({ planId, annual }) => {
const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId)
@ -480,13 +452,6 @@ 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
@ -503,6 +468,24 @@ 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])

View File

@ -14,12 +14,6 @@ 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 = {
@ -129,23 +123,16 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
const verifyPurchase = useCallback(
async (purchase: Purchase) => {
const productId = purchase.productId
let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken)
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(() => {}))
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),
)
if (!jwsRepresentation) {
throw new Error(t("subscription.actions.upgrade_error"))
}
const response = await followClient.request<{
@ -153,21 +140,23 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
data: BillingSubscriptionResponse
}>("/billing/apple/verify", {
method: "POST",
body: buildAppleVerificationRequest(purchase, signedTransactionInfo),
body: {
signedTransactionInfo: jwsRepresentation,
},
})
if (response.code !== 0) {
throw new Error("Failed to verify Apple subscription")
}
},
[validateReceipt],
[t, validateReceipt],
)
useEffect(() => {
if (
Platform.OS !== "ios" ||
!currentPurchase ||
!isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds)
!knownSubscriptionIds.has(currentPurchase.id)
) {
return
}
@ -291,7 +280,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
await new Promise((resolve) => setTimeout(resolve, 300))
const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>
isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds),
knownSubscriptionIds.has(purchase.id),
)
if (restoredPurchases.length === 0) {

View File

@ -1,72 +0,0 @@
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",
})
})
})

View File

@ -1,44 +0,0 @@
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<string | null | undefined>) =>
candidates.find(isCompactJws)
export const isKnownAppleSubscriptionPurchase = (
purchase: Pick<ApplePurchaseIdentity, "productId">,
knownSubscriptionIds: ReadonlySet<string>,
) => 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,
})

View File

@ -7,7 +7,7 @@ import { m, useAnimationControls } from "motion/react"
import { Fragment, useEffect, useState } from "react"
import * as React from "react"
export const NotFoundContent = () => {
const NotFoundContent = () => {
const [glitchText, setGlitchText] = useState("404")
const [isGlitching, setIsGlitching] = useState(false)

View File

@ -1,4 +1,3 @@
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"
@ -11,7 +10,6 @@ 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"
@ -91,43 +89,22 @@ export const Component = () => {
useTitle(user.data?.name)
if (user.isLoading) {
return <LoadingCircle size="large" className="center fixed inset-0" />
}
if (!user.data) {
if (user.error instanceof FollowAPIError && user.error.status === 404) {
return <NotFoundContent />
}
return <ProfileLoadError onRetry={() => void user.refetch()} />
}
return (
<Fragment>
<UserHero user={user.data} />
<Lists userId={user.data.id} />
{/* Subscriptions Section */}
<Subscriptions userId={user.data.id} />
</Fragment>
<>
{user.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
<Fragment>
<UserHero user={user.data!} />
<Lists userId={user.data?.id} />
{/* Subscriptions Section */}
<Subscriptions userId={user.data?.id} />
</Fragment>
)}
</>
)
}
const ProfileLoadError = ({ onRetry }: { onRetry: () => void }) => (
<div className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-6 text-center">
<i className="i-mgc-warning-fill mb-6 size-12 text-orange-500" />
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
Unable to load this profile
</h1>
<p className="mt-3 text-zinc-500 dark:text-zinc-400">
This may be a temporary problem. Please try again.
</p>
<Button buttonClassName="mt-8" onClick={onRetry}>
Try again
</Button>
</div>
)
const UserHero = ({ user }: { user: UserProfile }) => {
const subscriptions = useUserSubscriptionsQuery(user.id)

View File

@ -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 { sortByAlphabet } from "@follow/utils/utils"
import { isBizId, sortByAlphabet } from "@follow/utils/utils"
import type {
InboxSubscriptionResponse,
ListSubscriptionResponse,
@ -10,8 +10,6 @@ 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
)[]
@ -71,7 +69,13 @@ export const useUserSubscriptionsQuery = (userId: string | undefined) => {
}
export const fetchUser = async (handleOrId: string | undefined) => {
const res = await getUserProfile(followClient, handleOrId)
const handle = isBizId(handleOrId || "")
? handleOrId
: `${handleOrId}`.startsWith("@")
? `${handleOrId}`.slice(1)
: handleOrId
const res = await followClient.api.profiles.getProfile({ id: handleOrId, handle })
return res.data
}

View File

@ -10,7 +10,6 @@
"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": {

View File

@ -1,109 +0,0 @@
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</ScRiPt><script id="key-payload">`
const data = {
name: `</script><script id="data-payload">globalThis.__pwned__ = true</script><!--`,
characters: "<>&\u2028\u2029",
}
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>")
injectHydrationScript(document, key, data)
const serializedHtml = document.toString()
const { document: reparsedDocument } = parseHTML(serializedHtml)
const scripts = reparsedDocument.querySelectorAll("script")
const scriptSource = scripts[0]?.textContent
expect(scripts).toHaveLength(1)
expect(reparsedDocument.querySelector("#key-payload")).toBeNull()
expect(reparsedDocument.querySelector("#data-payload")).toBeNull()
expect(scriptSource).not.toContain("<")
expect(scriptSource).not.toContain(">")
expect(scriptSource).not.toContain("&")
expect(scriptSource).not.toContain("\u2028")
expect(scriptSource).not.toContain("\u2029")
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(scriptSource!, { window })
expect(window.__HYDRATE__?.[key]).toEqual(data)
})
it("preserves JSON.parse semantics for __proto__ properties", () => {
const data = JSON.parse(`{"__proto__":{"polluted":true}}`)
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(createHydrationScript("profile", data), { window })
const hydrated = window.__HYDRATE__?.profile as Record<string, unknown>
expect(Object.hasOwn(hydrated, "__proto__")).toBe(true)
expect((Object.getPrototypeOf(hydrated) as { polluted?: boolean }).polluted).toBeUndefined()
expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
})
it("defines a __proto__ hydration key without changing the store prototype", () => {
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(createHydrationScript("__proto__", { value: "safe" }), { window })
const hydrationStore = window.__HYDRATE__!
expect(Object.hasOwn(hydrationStore, "__proto__")).toBe(true)
expect(Object.getOwnPropertyDescriptor(hydrationStore, "__proto__")?.value).toEqual({
value: "safe",
})
expect((Object.getPrototypeOf(hydrationStore) as { value?: string }).value).toBeUndefined()
})
it("remains safe after production HTML and JavaScript minification", async () => {
const key = "profile"
const data = {
name: `</script><script id="minified-payload">globalThis.__pwned__ = true</script>`,
}
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>")
injectHydrationScript(document, key, data)
const minifiedHtml = await minify(document.toString(), {
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true,
collapseWhitespace: true,
html5: true,
minifyCSS: true,
minifyJS: true,
removeComments: true,
removeTagWhitespace: true,
})
const { document: reparsedDocument } = parseHTML(minifiedHtml)
const scripts = reparsedDocument.querySelectorAll("script")
const scriptSource = scripts[0]?.textContent
expect(scripts).toHaveLength(1)
expect(reparsedDocument.querySelector("#minified-payload")).toBeNull()
expect(scriptSource?.toLowerCase()).not.toContain("</script")
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(scriptSource!, { window })
expect(window.__HYDRATE__?.[key]).toEqual(data)
})
it("rejects top-level values that JSON cannot serialize", () => {
expect(() => createHydrationScript("profile", undefined)).toThrow(
"Hydration data must be JSON serializable",
)
})
})

View File

@ -1,38 +0,0 @@
const serializeJsonForInlineScript = (value: unknown): string => {
const serialized = JSON.stringify(value)
if (serialized === undefined) {
throw new TypeError("Hydration data must be JSON serializable")
}
return serialized
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029")
}
export const createHydrationScript = (key: string, data: unknown): string => {
const serializedData = JSON.stringify(data)
if (serializedData === undefined) {
throw new TypeError("Hydration data must be JSON serializable")
}
return `
window.__HYDRATE__ = window.__HYDRATE__ || {}
Object.defineProperty(window.__HYDRATE__, ${serializeJsonForInlineScript(key)}, {
configurable: true,
enumerable: true,
value: JSON.parse(${serializeJsonForInlineScript(serializedData)}),
writable: true,
})
`
}
export const injectHydrationScript = (document: Document, key: string, data: unknown): void => {
const script = document.createElement("script")
script.textContent = createHydrationScript(key, data)
document.head.append(script)
}

View File

@ -1,50 +0,0 @@
import type { FollowClient } from "@follow-app/client-sdk"
import { describe, expect, it, vi } from "vitest"
import { getUserProfile, resolveUserProfileParams } from "./user-profile-params"
vi.mock("@follow/utils/utils", () => ({
isBizId: (value: string | undefined) => value === "41125409313095680",
}))
describe("resolveUserProfileParams", () => {
it("uses a handle without also sending it as an id", () => {
expect(resolveUserProfileParams("DIYgod")).toEqual({
id: undefined,
handle: "DIYgod",
})
})
it("removes a leading at sign from handles", () => {
expect(resolveUserProfileParams("@DIYgod")).toEqual({
id: undefined,
handle: "DIYgod",
})
})
it("uses a business id without also sending it as a handle", () => {
expect(resolveUserProfileParams("41125409313095680")).toEqual({
id: "41125409313095680",
handle: undefined,
})
})
it("uses the resolved parameters for the profile request", async () => {
const getProfile = vi.fn().mockResolvedValue({ data: { id: "profile-id" } })
const apiClient = {
api: {
profiles: {
getProfile,
},
},
} as unknown as FollowClient
await getUserProfile(apiClient, "DIYgod")
expect(getProfile).toHaveBeenCalledOnce()
expect(getProfile).toHaveBeenCalledWith({
id: undefined,
handle: "DIYgod",
})
})
})

View File

@ -1,19 +0,0 @@
import { isBizId } from "@follow/utils/utils"
import type { FollowClient } from "@follow-app/client-sdk"
export const resolveUserProfileParams = (handleOrId: string | undefined) => {
if (isBizId(handleOrId || "")) {
return {
id: handleOrId,
handle: undefined,
}
}
return {
id: undefined,
handle: handleOrId?.startsWith("@") ? handleOrId.slice(1) : handleOrId,
}
}
export const getUserProfile = (apiClient: FollowClient, handleOrId: string | undefined) =>
apiClient.api.profiles.getProfile(resolveUserProfileParams(handleOrId))

View File

@ -9,7 +9,6 @@ import { FetchError } from "ofetch"
import path, { dirname, resolve } from "pathe"
import xss from "xss"
import { injectHydrationScript } from "../lib/hydration-script"
import { NotFoundError } from "../lib/not-found"
import { buildSeoMetaTags } from "../lib/seo"
import { injectMetaHandler, MetaError } from "../meta-handler"
@ -152,7 +151,13 @@ async function injectMetaToTemplate(document: Document, req: FastifyRequest, res
break
}
case "hydrate": {
injectHydrationScript(document, meta.key, meta.data)
// Insert hydrate script
const script = document.createElement("script")
script.innerHTML = `
window.__HYDRATE__ = window.__HYDRATE__ || {}
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
`
document.head.append(script)
break
}
}

View File

@ -62,8 +62,7 @@ export const ogRoute = (app: FastifyInstance) => {
const createErrorFallback = (reply: FastifyReply) => (code: number | Error) => {
if (typeof code !== "number" && code instanceof Error) {
console.error("OG render error:", code)
reply.code(500).send("Internal server error")
reply.code(500).send(code.message)
return null
}
let message = "Internal server error"

View File

@ -1,12 +1,17 @@
import { isBizId } from "@follow/utils/utils"
import type { FollowClient } from "@follow-app/client-sdk"
import * as React from "react"
import { renderToImage } from "../../lib/og/render-to-image"
import { getUserProfile } from "../../lib/user-profile-params"
import { getImageBase64, OGAvatar, OGCanvas } from "./__base"
export const renderUserOG = async (apiClient: FollowClient, handleOrId: string) => {
const user = await getUserProfile(apiClient, handleOrId)
export const renderUserOG = async (apiClient: FollowClient, id: string) => {
const handle = isBizId(id || "") ? id : `${id}`.startsWith("@") ? `${id}`.slice(1) : id
const user = await apiClient.api.profiles.getProfile({
id,
handle,
})
if (!user) {
throw 404

View File

@ -38,7 +38,6 @@
"./client/**/*.tsx",
"./types/**/*.d.ts",
"vite.config.mts",
"vitest.config.ts",
"./tailwind.config.ts",
"./helper/**/*.ts"
],

View File

@ -1,7 +0,0 @@
import { defineProject } from "vitest/config"
export default defineProject({
test: {
environment: "node",
},
})

View File

@ -12,7 +12,6 @@ import xss from "xss"
import resvgWasm from "./resvg.wasm"
// OG image rendering
import { createFollowClient } from "./src/lib/api-client"
import { injectHydrationScript } from "./src/lib/hydration-script"
import { NotFoundError } from "./src/lib/not-found"
import { setFontsBucket } from "./src/lib/og/fonts.worker"
import { setWasmModule } from "./src/lib/og/resvg-wasm-shim"
@ -128,7 +127,7 @@ app.get("/og/:type/:id", async (c) => {
return c.text(e === 404 ? "Not found" : "Internal server error", e)
}
console.error("OG render error:", e)
return c.text("Internal server error", 500)
return c.text(e?.message || "Internal server error", 500)
}
if (!imageRes) {
@ -299,7 +298,12 @@ async function injectMetaToTemplate(document: Document, c: any) {
break
}
case "hydrate": {
injectHydrationScript(document, meta.key, meta.data)
const script = document.createElement("script")
script.innerHTML = `
window.__HYDRATE__ = window.__HYDRATE__ || {}
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
`
document.head.append(script)
break
}
}

View File

@ -3,11 +3,7 @@
"name": "folo-ssr",
"main": "dist/worker/worker-entry.mjs",
"compatibility_date": "2026-02-01",
"compatibility_flags": [
"nodejs_compat",
// The SSR Worker fetches api.folo.is, another Worker Route in the same zone.
"global_fetch_strictly_public",
],
"compatibility_flags": ["nodejs_compat"],
"observability": {
"logs": {
"enabled": true,

View File

@ -19,7 +19,6 @@
"2004": "Feed failed to parse",
"2010": "Ownership challenge failed",
"2011": "Subscription limit exceeded",
"2012": "RSSHub feed subscription limit exceeded",
"3000": "Entry not found",
"4000": "Already claimed",
"4001": "User wallet error",

View File

@ -19,7 +19,6 @@
"2004": "Échec de l'analyse du flux",
"2010": "Échec de la vérification de propriété",
"2011": "Limite d'abonnement dépassée",
"2012": "Limite d'abonnement aux flux RSSHub dépassée",
"3000": "Entrée introuvable",
"4000": "Déjà réclamé",
"4001": "Erreur de portefeuille utilisateur",

View File

@ -19,7 +19,6 @@
"2004": "フィードの解析に失敗しました",
"2010": "所有権の確認に失敗しました",
"2011": "購読制限を超えました",
"2012": "RSSHubフィードの購読数制限を超えました",
"3000": "エントリが見つかりません",
"4000": "すでに請求されています",
"4001": "ユーザーウォレットエラー",

View File

@ -19,7 +19,6 @@
"2004": "订阅源解析失败",
"2010": "所有权挑战失败",
"2011": "超出订阅限制",
"2012": "RSSHub 订阅源数量限制已超出",
"3000": "未找到条目",
"4000": "已被认领",
"4001": "用户钱包错误",

View File

@ -19,7 +19,6 @@
"2004": "訂閱源解析失敗",
"2010": "所有權挑戰失敗",
"2011": "超過訂閱限制",
"2012": "RSSHub 訂閱源數量限制已超過",
"3000": "條目未找到",
"4000": "已被認領",
"4001": "用戶錢包錯誤",

View File

@ -104,7 +104,7 @@
}
[data-theme="dark"] .kbd {
border: 1px solid hsl(var(--fo-border) / 0.5);
border: 1px solid hsl(var(--border) / 0.5);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.12),
0 1px 4px rgba(0, 0, 0, 0.08),

View File

@ -46,8 +46,7 @@ const twConfig = {
},
colors: {
// DaisyUI 5 reserves --border for border width, so keep the color token namespaced.
border: "hsl(var(--fo-border) / <alpha-value>)",
border: "hsl(var(--border) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
accent: "hsl(var(--fo-a) / <alpha-value>)",

View File

@ -15,8 +15,7 @@
--background: 0 0% 100%;
--color-background: 255 255 255;
--fo-border: 20 5.9% 90%;
--border: var(--fo-border);
--border: 20 5.9% 90%;
--radius: 0.5rem;
--fo-selection-active: theme(colors.accent/90);
@ -40,8 +39,7 @@
--background: 0 0% 7.1%;
--color-background: 18 18 18;
--fo-border: 0 0% 22.1%;
--border: var(--fo-border);
--border: 0 0% 22.1%;
}
}

View File

@ -10,8 +10,7 @@
--background: 0 0% 100%;
--color-background: 255 255 255;
--fo-border: 20 5.9% 90%;
--border: var(--fo-border);
--border: 20 5.9% 90%;
--radius: 0.5rem;
}
@ -19,8 +18,7 @@
--background: 0 0% 7.1%;
--color-background: 18 18 18;
--fo-border: 0 0% 22.1%;
--border: var(--fo-border);
--border: 0 0% 22.1%;
}
}

View File

@ -1398,8 +1398,8 @@ importers:
specifier: 4.1.2
version: 4.1.2(patch_hash=dc51df1dccb62feafc2369115ba8e02dba9b261d9cf397f4db6c57d6b4acefe5)(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)
react-native-uikit-colors:
specifier: 0.6.2
version: 0.6.2(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3)))
specifier: 1.0.0
version: 1.0.0(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3)))
react-native-volume-manager:
specifier: 2.0.8
version: 2.0.8(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)
@ -10314,9 +10314,6 @@ packages:
os: [darwin]
hasBin: true
apple-uikit-colors@0.6.2:
resolution: {integrity: sha512-sv0b92krbTZGNt4AJQLAPioUvuEI/wW2cLqGuEBWl3KpU2JK/odOftx8+dGSC5jGWhdB5qnwBqF96X2FA8FUQA==}
apple-uikit-colors@1.0.0:
resolution: {integrity: sha512-G2Ti2ogMOOC1phfHacSrLacDiE0RcLI7IG3aNoaw7Ack23WI8/9QczmUuhxxkU9BoIR2euCZ2G4lD5qJ+OknwA==}
@ -16814,14 +16811,6 @@ packages:
shaka-player:
optional: true
react-native-uikit-colors@0.6.2:
resolution: {integrity: sha512-qcsQOb/0+3eoYPS/pJG8R5Idxnk+o/UKPwTfa4wCxJ/gFJEcShm1q0wsw9D7BgceXo8tK+aMDebeguvNJYt6Og==}
peerDependencies:
nativewind: '>=4.1.0'
react: 19.2.7
react-native: '>=0.76.0'
tailwindcss: '>=3.0.0'
react-native-uikit-colors@1.0.0:
resolution: {integrity: sha512-DEEc/OTrNNkIgfgbjSsG4pL0pvBaqtwmjN4FCITHQYeJFupxPOcGJehatMfPE6x3QItyIe45C0sYZz/ish+x6w==}
peerDependencies:
@ -17913,9 +17902,6 @@ packages:
peerDependencies:
tailwindcss: ^4.0.0
tailwindcss-uikit-colors@0.6.2:
resolution: {integrity: sha512-RfF3VVB2nHvJwPZ2ZSTMgKYbZjRny/KW3rP6ufg6/bopxINDQ9TpN/fJXxvKnD86hIU0F2oLIfAv2r6o/ZWcZw==}
tailwindcss-uikit-colors@1.0.0:
resolution: {integrity: sha512-18MGdMVSoXFKjcVkUIu7Q5USqdenAvgFJT9DEocPmdS94b8d/z3s0Uw8LvaNDINfDN9ls24hfg6/hyFqpcZpxw==}
@ -29548,8 +29534,6 @@ snapshots:
repeat-string: 1.6.1
optional: true
apple-uikit-colors@0.6.2: {}
apple-uikit-colors@1.0.0: {}
archiver-utils@2.1.0:
@ -37515,15 +37499,6 @@ snapshots:
react: 19.2.7
react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)
react-native-uikit-colors@0.6.2(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))):
dependencies:
apple-uikit-colors: 0.6.2
nativewind: 4.2.6(ea162fae5d41964a07e45415df8f37cb)
react: 19.2.7
react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)
tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))
tailwindcss-uikit-colors: 0.6.2
react-native-uikit-colors@1.0.0(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))):
dependencies:
apple-uikit-colors: 1.0.0
@ -38911,10 +38886,6 @@ snapshots:
dependencies:
tailwindcss: 4.3.2
tailwindcss-uikit-colors@0.6.2:
dependencies:
apple-uikit-colors: 0.6.2
tailwindcss-uikit-colors@1.0.0:
dependencies:
apple-uikit-colors: 1.0.0