Compare commits

...

44 Commits

Author SHA1 Message Date
DIYgod 3846c90b67
fix(ci): scope release detection to target branch (#5065) 2026-08-01 23:14:33 +08:00
github-actions[bot] 3bc8336562
Merge pull request #5068 from RSSNext/mobile-main
chore(sync): merge mobile-main into dev
2026-08-01 10:37:48 +00:00
DIYgod a580e0a337
Merge pull request #5067 from RSSNext/release/mobile/0.5.8
release(mobile): Release v0.5.8
2026-08-01 18:28:33 +08:00
DIYgod f6777ee559 release(mobile): release v0.5.8 2026-08-01 17:45:07 +08:00
DIYgod 6a8c07c000 docs(mobile): prepare release metadata 2026-08-01 17:44:47 +08:00
DIYgod e291c3d1da fix(mobile): comply with Android media permissions policy 2026-08-01 17:41:46 +08:00
DIYgod efafb15d7c
Merge pull request #5064 from RSSNext/mobile-main
chore(sync): merge mobile-main into dev
2026-07-31 19:28:49 +08:00
DIYgod 4dea1853b6 release(mobile): Release v0.5.7 2026-07-31 19:03:10 +08:00
DIYgod 07dd7e27ab
Merge pull request #5063 from RSSNext/mobile-main
chore(sync): merge mobile-main into dev
2026-07-31 18:39:13 +08:00
DIYgod 48ce50d034
release(mobile): Release v0.5.7 (#5061)
* release(desktop): release v1.11.0

* docs(desktop): restore mobile release inputs

* fix(desktop): use js-yaml ESM exports

* fix(desktop): use js-yaml ESM exports

* fix(desktop): package jsdom runtime dependencies

* fix(desktop): package jsdom runtime dependencies

* fix(desktop): use js-yaml ESM exports for Windows metadata

* build(deps): bump actions/setup-node from 6 to 7 (#5046)

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(ssr): escape hydration data in inline scripts

* fix(ssr): restore shared user profiles

* fix: make Codex worktree setup portable

* fix(mobile): restore dark mode text colors

* fix(styles): avoid DaisyUI border token collision

* fix(desktop): restore category chevron rotation

* fix(subscription): recover past-due Stripe checkout

* fix(desktop): dismiss share popover after actions

* fix(mobile): handle RSSHub subscription limit errors

* fix(desktop/ipc): allowlist URL scheme protocols in openURLScheme (#5056)

The 'integration.openURLScheme' IPC method invokes 'shell.openExternal'
with a renderer-supplied string after only checking that it contains
'://'. Electron's documentation explicitly warns that passing untrusted
URLs to 'shell.openExternal' is unsafe: schemes such as 'file://',
'smb://', 'ms-msdt:', 'search-ms:', 'jar:', 'res:', 'javascript:',
'data:' and 'vbscript:' have well-known abuse chains (local file
disclosure, NTLM credential theft over SMB on Windows, MSDT/Follina-style
RCE, etc.). Because the renderer process can also reach this IPC via any
XSS sink in untrusted RSS feed content, the previous validation was not
sufficient.

Replace the substring check with strict URL parsing plus an allowlist of
protocols that match the integration use-cases documented in the UI
(Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus generic
http/https/mailto. All other protocols are rejected with a clear error.

Adds vitest cases for representative dangerous schemes (verifying that
'shell.openExternal' is never invoked) and for every scheme shipped as a
built-in example, so future regressions on either side are caught.

* chore(ci): format integration URL scheme test

* fix(mobile): use product IDs for Apple IAP verification

* docs(desktop): prepare release inputs

* docs(mobile): prepare release metadata

* release(mobile): release v0.5.7

* docs(mobile): restore desktop release inputs

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sebastion <sebastion@sebastion.dev>
2026-07-31 18:32:29 +08:00
github-actions[bot] ec8838083e
Merge pull request #5062 from RSSNext/main
chore(sync): merge main into dev
2026-07-31 10:16:13 +00:00
DIYgod 0eb0b13dab
release(desktop): Release v1.12.0 (#5060)
* release(mobile): release v0.5.6

* docs(mobile): restore desktop release inputs

* fix(desktop): use js-yaml ESM exports

* fix(desktop): package jsdom runtime dependencies

* fix(desktop): use js-yaml ESM exports for Windows metadata

* build(deps): bump actions/setup-node from 6 to 7 (#5046)

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(ssr): escape hydration data in inline scripts

* fix(ssr): restore shared user profiles

* fix: make Codex worktree setup portable

* fix(mobile): restore dark mode text colors

* fix(styles): avoid DaisyUI border token collision

* fix(desktop): restore category chevron rotation

* fix(subscription): recover past-due Stripe checkout

* fix(desktop): dismiss share popover after actions

* fix(mobile): handle RSSHub subscription limit errors

* fix(desktop/ipc): allowlist URL scheme protocols in openURLScheme (#5056)

The 'integration.openURLScheme' IPC method invokes 'shell.openExternal'
with a renderer-supplied string after only checking that it contains
'://'. Electron's documentation explicitly warns that passing untrusted
URLs to 'shell.openExternal' is unsafe: schemes such as 'file://',
'smb://', 'ms-msdt:', 'search-ms:', 'jar:', 'res:', 'javascript:',
'data:' and 'vbscript:' have well-known abuse chains (local file
disclosure, NTLM credential theft over SMB on Windows, MSDT/Follina-style
RCE, etc.). Because the renderer process can also reach this IPC via any
XSS sink in untrusted RSS feed content, the previous validation was not
sufficient.

Replace the substring check with strict URL parsing plus an allowlist of
protocols that match the integration use-cases documented in the UI
(Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus generic
http/https/mailto. All other protocols are rejected with a clear error.

Adds vitest cases for representative dangerous schemes (verifying that
'shell.openExternal' is never invoked) and for every scheme shipped as a
built-in example, so future regressions on either side are caught.

* chore(ci): format integration URL scheme test

* fix(mobile): use product IDs for Apple IAP verification

* docs(desktop): prepare release inputs

* docs(mobile): prepare release metadata

* release(desktop): release v1.12.0

* docs(desktop): restore mobile release inputs

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sebastion <sebastion@sebastion.dev>
2026-07-31 18:09:55 +08:00
DIYgod 376e6e0343 docs(mobile): restore desktop release inputs 2026-07-31 16:31:48 +08:00
DIYgod eb782e0389 release(mobile): release v0.5.7 2026-07-31 16:31:13 +08:00
DIYgod c8a0aa0b4f docs(desktop): restore mobile release inputs 2026-07-31 16:29:28 +08:00
DIYgod e0c5d6186a release(desktop): release v1.12.0 2026-07-31 16:28:50 +08:00
DIYgod 8a2babfd4f docs(mobile): prepare release metadata 2026-07-31 16:27:59 +08:00
DIYgod 2d9c026cca docs(desktop): prepare release inputs 2026-07-31 16:27:49 +08:00
DIYgod a27db571b3 fix(mobile): use product IDs for Apple IAP verification 2026-07-30 19:27:59 +08:00
DIYgod dd2c933936 chore(ci): format integration URL scheme test 2026-07-28 13:52:45 +08:00
Sebastion bd91b015ee
fix(desktop/ipc): allowlist URL scheme protocols in openURLScheme (#5056)
The 'integration.openURLScheme' IPC method invokes 'shell.openExternal'
with a renderer-supplied string after only checking that it contains
'://'. Electron's documentation explicitly warns that passing untrusted
URLs to 'shell.openExternal' is unsafe: schemes such as 'file://',
'smb://', 'ms-msdt:', 'search-ms:', 'jar:', 'res:', 'javascript:',
'data:' and 'vbscript:' have well-known abuse chains (local file
disclosure, NTLM credential theft over SMB on Windows, MSDT/Follina-style
RCE, etc.). Because the renderer process can also reach this IPC via any
XSS sink in untrusted RSS feed content, the previous validation was not
sufficient.

Replace the substring check with strict URL parsing plus an allowlist of
protocols that match the integration use-cases documented in the UI
(Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus generic
http/https/mailto. All other protocols are rejected with a clear error.

Adds vitest cases for representative dangerous schemes (verifying that
'shell.openExternal' is never invoked) and for every scheme shipped as a
built-in example, so future regressions on either side are caught.
2026-07-28 13:40:13 +08:00
DIYgod 2350884eae fix(mobile): handle RSSHub subscription limit errors 2026-07-26 07:29:14 +08:00
DIYgod 52f587dacb fix(desktop): dismiss share popover after actions 2026-07-25 20:23:44 +08:00
DIYgod 3d65478fb6 fix(subscription): recover past-due Stripe checkout 2026-07-25 20:22:55 +08:00
DIYgod 22c8a667e6 fix(desktop): restore category chevron rotation 2026-07-25 20:18:50 +08:00
DIYgod 2034702df6 fix(styles): avoid DaisyUI border token collision 2026-07-25 20:17:55 +08:00
DIYgod 764c70b48c fix(mobile): restore dark mode text colors 2026-07-25 18:20:36 +08:00
DIYgod a9e2b1a818 fix: make Codex worktree setup portable 2026-07-25 15:31:42 +08:00
DIYgod 110960a6f0 fix(ssr): restore shared user profiles 2026-07-25 15:06:20 +08:00
DIYgod cf224d63f5 fix(ssr): escape hydration data in inline scripts 2026-07-25 12:38:49 +08:00
dependabot[bot] 773f1bfe21
build(deps): bump actions/setup-node from 6 to 7 (#5046)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 09:00:49 +08:00
DIYgod 192a7139ce fix(desktop): use js-yaml ESM exports for Windows metadata 2026-07-13 13:03:42 +08:00
DIYgod 6000787cb8 fix(desktop): use js-yaml ESM exports for Windows metadata 2026-07-13 13:03:25 +08:00
DIYgod c3eb7285f9
Merge pull request #5044 from RSSNext/main
chore(sync): merge main into dev
2026-07-13 12:37:24 +08:00
DIYgod d7365aebf4
Merge pull request #5041 from RSSNext/release/desktop/1.11.0
release(desktop): Release v1.11.0
2026-07-13 12:36:53 +08:00
DIYgod d90cdfe2b3 fix(desktop): package jsdom runtime dependencies 2026-07-13 12:13:57 +08:00
DIYgod 713c53352d fix(desktop): package jsdom runtime dependencies 2026-07-13 12:12:50 +08:00
DIYgod c231c05ed9
Merge pull request #5043 from RSSNext/mobile-main
chore(sync): merge mobile-main into dev
2026-07-13 11:47:28 +08:00
DIYgod 8f7471951d
Merge pull request #5042 from RSSNext/release/mobile/0.5.6
release(mobile): Release v0.5.6
2026-07-13 11:46:49 +08:00
DIYgod 57fd759f25 fix(desktop): use js-yaml ESM exports 2026-07-13 11:33:09 +08:00
DIYgod d726ccccb2 fix(desktop): use js-yaml ESM exports 2026-07-13 11:32:42 +08:00
DIYgod 06135760ba docs(mobile): restore desktop release inputs 2026-07-13 11:14:43 +08:00
DIYgod 7c4d51a642 release(mobile): release v0.5.6 2026-07-13 11:14:04 +08:00
DIYgod c3fae852aa docs(desktop): restore mobile release inputs 2026-07-13 11:13:00 +08:00
75 changed files with 1606 additions and 198 deletions

View File

@ -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
'''

View File

@ -7,13 +7,21 @@
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
import { appendFileSync } from "node:fs" import { appendFileSync } from "node:fs"
import { pathToFileURL } from "node:url"
// Configuration // Configuration
const RELEASE_PATTERNS = { const RELEASE_PATTERNS = {
desktop: /release\(desktop\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i, desktop: /^release\(desktop\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/i,
mobile: /release\(mobile\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i, mobile: /^release\(mobile\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/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 = { const EXIT_CODES = {
SUCCESS: 0, SUCCESS: 0,
GIT_ERROR: 2, GIT_ERROR: 2,
@ -57,7 +65,6 @@ function setGitHubOutput(key, value) {
/** /**
* Get the latest commit message * Get the latest commit message
* @returns {string} Latest commit message
*/ */
function getLatestCommitMessage() { function getLatestCommitMessage() {
try { try {
@ -69,21 +76,45 @@ function getLatestCommitMessage() {
} }
/** /**
* Extract release information from commit message * 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.
* @param {string} commitMessage - Git commit message * @param {string} commitMessage - Git commit message
* @returns {Object|null} Release information or null if no release found * @param {string|undefined} refName - GitHub ref name
* @returns {{platform: string, version: string, tagName: string}|null} Release information or null
*/ */
function extractReleaseInfo(commitMessage) { export function extractReleaseInfo(commitMessage, refName = process.env.GITHUB_REF_NAME) {
for (const [platform, regex] of Object.entries(RELEASE_PATTERNS)) { const [commitSubject = "", ...commitBodyLines] = commitMessage.split(/\r?\n/)
const match = commitMessage.match(regex) const expectedPlatform = refName ? RELEASE_PLATFORM_BY_REF[refName] : undefined
if (match) {
const version = match[1]
const tagName = `${platform}/${version}`
return { if (refName && !expectedPlatform) {
platform, return null
version, }
tagName,
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,
}
} }
} }
} }
@ -128,4 +159,6 @@ function main() {
} }
} }
main() if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}

View File

@ -0,0 +1,161 @@
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 uses: pnpm/action-setup@v6
- name: 🏗 Setup Node.js - name: 🏗 Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: 22 node-version: 22
cache: "pnpm" cache: "pnpm"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: "pnpm" cache: "pnpm"
@ -52,5 +52,10 @@ jobs:
run: | run: |
export NODE_OPTIONS="--max_old_space_size=16384" export NODE_OPTIONS="--max_old_space_size=16384"
npm exec turbo run format:check typecheck lint 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 - name: Run test
run: npm exec turbo run test run: npm exec turbo run test

View File

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

View File

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

7
.gitignore vendored
View File

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

2
.worktreeinclude Normal file
View File

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

View File

@ -0,0 +1,17 @@
# 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,7 +36,43 @@ const ymlMapsMap = {
win32: "latest.yml", 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"]) const keepLanguages = new Set(["en", "en_GB", "en-US", "en_US"])
// remove folders & files not to be included in the app // 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 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 = { const config: ForgeConfig = {
packagerConfig: { packagerConfig: {

View File

@ -1,8 +1,9 @@
import fsp from "node:fs/promises" import fsp from "node:fs/promises"
import os from "node:os" import os from "node:os"
import { shell } from "electron"
import path from "pathe" 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" import { IntegrationService } from "./integration"
@ -69,4 +70,70 @@ describe("IntegrationService", () => {
fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2追放 索米·雪兔献礼 1")), fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2追放 索米·雪兔献礼 1")),
).rejects.toThrow() ).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,6 +81,29 @@ 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 { export class IntegrationService extends IpcService {
static override readonly groupName = "integration" static override readonly groupName = "integration"
@ -382,11 +405,32 @@ ${content}
const requestId = Math.random().toString(36).slice(2, 8) const requestId = Math.random().toString(36).slice(2, 8)
try { try {
// Validate URL scheme format // Parse and validate the protocol up-front. `shell.openExternal` will
if (!scheme.includes("://")) { // 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://')") 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) // Log URL scheme execution (mask sensitive data)
const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) => const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>
// Mask potential sensitive query parameters // Mask potential sensitive query parameters
@ -399,7 +443,7 @@ ${content}
logger.info(`[URLScheme:${requestId}] Opening URL scheme`, { logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {
scheme: safeScheme, scheme: safeScheme,
protocol: scheme.split("://")[0], protocol,
}) })
// Use Electron's shell.openExternal to open URL scheme // 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"> { export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
/** Custom z-index for popover */ /** Custom z-index for popover */
zIndex?: number zIndex?: number
/** Whether the popover should close when clicked outside */ /** Whether the popover should use modal focus and pointer behavior */
modal?: boolean modal?: boolean
} }
@ -33,6 +33,11 @@ export const showPopover = (
element: ReactNode, element: ReactNode,
props?: PopoverProps, props?: PopoverProps,
) => { ) => {
const currentPopover = jotaiStore.get(popoverAtom)
if (currentPopover.open) {
currentPopover.abortController.abort()
}
jotaiStore.set(popoverAtom, { jotaiStore.set(popoverAtom, {
open: true, open: true,
position: mouseXY, position: mouseXY,
@ -41,3 +46,11 @@ export const showPopover = (
abortController: new AbortController(), 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

@ -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(<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,6 +5,7 @@ import { useCallback } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
import { dismissPopover } from "~/atoms/popover"
import { ipcServices } from "~/lib/client" import { ipcServices } from "~/lib/client"
import { copyToClipboard } from "~/lib/clipboard" import { copyToClipboard } from "~/lib/clipboard"
@ -140,11 +141,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
await copyToClipboard(shareContent.url) await copyToClipboard(shareContent.url)
toast.success(t("share.link_copied")) toast.success(t("share.link_copied"))
} }
dismissPopover()
} catch { } catch {
// If sharing fails, copy link as fallback // If sharing fails, copy link as fallback
try { try {
await copyToClipboard(shareContent.url) await copyToClipboard(shareContent.url)
toast.success(t("share.link_copied")) toast.success(t("share.link_copied"))
dismissPopover()
} catch { } catch {
toast.error(t("share.copy_failed")) toast.error(t("share.copy_failed"))
} }
@ -156,6 +159,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
try { try {
await copyToClipboard(shareUrl) await copyToClipboard(shareUrl)
toast.success(t("share.link_copied")) toast.success(t("share.link_copied"))
dismissPopover()
} catch { } catch {
toast.error(t("share.copy_failed")) toast.error(t("share.copy_failed"))
} }
@ -178,6 +182,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
.replace("{text}", shareText) .replace("{text}", shareText)
window.open(finalUrl, "_blank", "width=600,height=400") window.open(finalUrl, "_blank", "width=600,height=400")
dismissPopover()
}, },
[entryId, generateShareContent], [entryId, generateShareContent],
) )

View File

@ -10,6 +10,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"
import type { TFunction } from "i18next" import type { TFunction } from "i18next"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs" import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
import { useIsPaymentEnabled, useServerConfigs } 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" import { subscription } from "~/lib/auth"
const APPLE_SUBSCRIPTION_MANAGEMENT_URL = "https://apps.apple.com/account/subscriptions" 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 = { type ActiveSubscription = {
source: "stripe" | "apple" | null source: "stripe" | "apple" | null
@ -83,6 +93,29 @@ const formatFeatureValue = (
return value 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 }) => { const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: boolean }) => {
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -97,10 +130,20 @@ const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: bo
cancelUrl: env.VITE_WEB_URL, cancelUrl: env.VITE_WEB_URL,
disableRedirect: IN_ELECTRON, 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) { if (IN_ELECTRON && res.data?.url) {
window.open(res.data.url, "_blank") window.open(res.data.url, "_blank")
} }
}, },
onError: (error) => {
toast.error(error.message)
},
}) })
} }
@ -120,20 +163,9 @@ const useActiveSubscription = () => {
const useBillingPortal = () => { const useBillingPortal = () => {
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: openStripeBillingPortal,
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href onError: (error) => {
const res = await fetch(`${env.VITE_API_URL}/billing/portal`, { toast.error(error.message)
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} onClick={handleCollapseButtonClick}
data-state={open ? "open" : "close"} data-state={open ? "open" : "close"}
className={cn( 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} tabIndex={-1}
> >

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "Folo", "name": "Folo",
"type": "module", "type": "module",
"version": "1.11.0", "version": "1.12.0",
"private": true, "private": true,
"description": "Follow everything in one place", "description": "Follow everything in one place",
"author": "Folo Team", "author": "Folo Team",
@ -99,7 +99,7 @@
"workbox-build": "7.4.1", "workbox-build": "7.4.1",
"workbox-window": "7.4.1" "workbox-window": "7.4.1"
}, },
"runtimeVersion": "1.11.0", "runtimeVersion": "1.12.0",
"productName": "Folo", "productName": "Folo",
"mainHash": "b672f321b6478cbaad0ad6e354d73ca3c8a79f90aaadb41c119499639ec66e87" "mainHash": "0fdc4db0cd19e2c2d6cf6c596aa1a62cc253b4c9612633470cb6b8d64fba403a"
} }

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import crypto from "node:crypto"
import fs from "node:fs" import fs from "node:fs"
import { fileURLToPath, resolve } from "node:url" 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 __dirname = fileURLToPath(new URL(".", import.meta.url))
const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/") const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/")

View File

@ -111,6 +111,17 @@ export default ({ config }: ConfigContext): ExpoConfig => {
}, },
android: { android: {
package: "is.follow", 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: { adaptiveIcon: {
foregroundImage: adaptiveIconPath, foregroundImage: adaptiveIconPath,
monochromeImage: adaptiveIconPath, monochromeImage: adaptiveIconPath,
@ -158,7 +169,8 @@ export default ({ config }: ConfigContext): ExpoConfig => {
{ {
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.", photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.", savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
isAccessMediaLocationEnabled: true, isAccessMediaLocationEnabled: false,
granularPermissions: [],
}, },
], ],
"expo-apple-authentication", "expo-apple-authentication",
@ -192,6 +204,8 @@ export default ({ config }: ConfigContext): ExpoConfig => {
"expo-image-picker", "expo-image-picker",
{ {
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.", photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
cameraPermission: false,
microphonePermission: false,
}, },
], ],
[ [

View File

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

@ -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

View File

@ -0,0 +1,5 @@
# 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,14 +1,11 @@
# What's New in vNEXT_VERSION # What's New in vNEXT_VERSION
## Shiny new things
## Improvements ## Improvements
- Upgraded the app to Expo SDK 57, React Native 0.86, and updated native integrations
## No longer broken ## No longer broken
- Fixed social sign-in callbacks and legacy session migration using the Folo app scheme ## Thanks
- Fixed signed-out launches not opening the login screen
- Fixed two-factor authentication cookies being dropped during session updates Special thanks to volunteer contributors @ for their valuable contributions
- 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> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.5.5</string> <string>0.5.8</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -54,7 +54,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>8</string> <string>11</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>

View File

@ -1,6 +1,6 @@
{ {
"name": "@follow/mobile", "name": "@follow/mobile",
"version": "0.5.5", "version": "0.5.8",
"private": true, "private": true,
"main": "src/main.tsx", "main": "src/main.tsx",
"scripts": { "scripts": {
@ -135,7 +135,7 @@
"react-native-sheet-transitions": "0.1.2", "react-native-sheet-transitions": "0.1.2",
"react-native-svg": "15.15.5", "react-native-svg": "15.15.5",
"react-native-track-player": "4.1.2", "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-volume-manager": "2.0.8",
"react-native-web": "0.21.2", "react-native-web": "0.21.2",
"react-native-webview": "14.0.1", "react-native-webview": "14.0.1",

View File

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

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { resolveRuntimeVersion } from "../app.config.base" import createExpoConfig, { resolveRuntimeVersion } from "../app.config.base"
describe("resolveRuntimeVersion", () => { describe("resolveRuntimeVersion", () => {
it("keeps the development runtime version stable", () => { it("keeps the development runtime version stable", () => {
@ -33,3 +33,42 @@ describe("resolveRuntimeVersion", () => {
).toThrow(/OTA_RUNTIME_VERSION/i) ).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

@ -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)
}
})
})

View File

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

View File

@ -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")
})
})

View File

@ -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()

View File

@ -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")
})
})

View File

@ -5,6 +5,7 @@ import { FetchError } from "ofetch"
import { getIsPaymentEnabled } from "@/src/atoms/server-configs" import { getIsPaymentEnabled } from "@/src/atoms/server-configs"
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog" import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
import { sanitizeErrorMessage } from "./error-message"
import { toast } from "./toast" import { toast } from "./toast"
export const getFetchErrorInfo = ( export const getFetchErrorInfo = (
@ -21,11 +22,11 @@ export const getFetchErrorInfo = (
const i18nKey = `errors:${code}` as any const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey) const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
return { return {
message: `${i18nMessage}${reason ? `: ${reason}` : ""}`, message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`),
code, code,
} }
} catch { } catch {
return { message: error.message } return { message: sanitizeErrorMessage(error.message) }
} }
} }
@ -35,15 +36,15 @@ export const getFetchErrorInfo = (
const i18nKey = `errors:${code}` as any const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey) const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
return { return {
message: i18nMessage, message: sanitizeErrorMessage(i18nMessage),
code, code,
} }
} catch { } catch {
return { message: error.message } return { message: sanitizeErrorMessage(error.message) }
} }
} }
return { message: error.message } return { message: sanitizeErrorMessage(error.message) }
} }
export const getFetchErrorMessage = (error: Error) => { export const getFetchErrorMessage = (error: Error) => {
@ -58,7 +59,7 @@ export const createErrorToaster = (title?: string) => (err: Error) =>
toastFetchError(err, { title }) toastFetchError(err, { title })
export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => { export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {
const { message: fallbackMessage } = error const fallbackMessage = sanitizeErrorMessage(error.message)
let message = fallbackMessage let message = fallbackMessage
let _reason = "" let _reason = ""
let code: number | undefined 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 // 2fa errors are handled by the form
if (code === 4007 || code === 4008) { if (code === 4007 || code === 4008) {
return return

View File

@ -76,6 +76,7 @@ const PLAN_FEATURE_ORDER: Array<keyof PaymentFeature> = [
] ]
const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"] const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"]
const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS"
type SegmentLayout = { type SegmentLayout = {
width: number width: number
@ -110,6 +111,14 @@ type ActiveSubscription = {
canManage: boolean canManage: boolean
} }
type BillingPortalResponse = {
code: number
data?: {
url: string
}
message?: string
}
const currencyFormatter = (() => { const currencyFormatter = (() => {
try { try {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
@ -425,6 +434,25 @@ export const PlanScreen: NavigationControllerView = () => {
return Math.round(total / paidPlans.length) return Math.round(total / paidPlans.length)
}, [sortedPlans]) }, [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>({ const upgradeMutation = useMutation<void, Error, UpgradeVariables>({
mutationFn: async ({ planId, annual }) => { mutationFn: async ({ planId, annual }) => {
const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId) const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId)
@ -452,6 +480,13 @@ export const PlanScreen: NavigationControllerView = () => {
cancelUrl: proxyEnv.WEB_URL, cancelUrl: proxyEnv.WEB_URL,
disableRedirect: true, 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 = const redirectUrl =
typeof response === "object" && response && "data" in response && response.data 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(() => { const handleManageSubscription = useCallback(() => {
billingPortalMutation.mutate() billingPortalMutation.mutate()
}, [billingPortalMutation]) }, [billingPortalMutation])

View File

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

View File

@ -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",
})
})
})

View File

@ -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<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 { Fragment, useEffect, useState } from "react"
import * as React from "react" import * as React from "react"
const NotFoundContent = () => { export const NotFoundContent = () => {
const [glitchText, setGlitchText] = useState("404") const [glitchText, setGlitchText] = useState("404")
const [isGlitching, setIsGlitching] = useState(false) const [isGlitching, setIsGlitching] = useState(false)

View File

@ -1,3 +1,4 @@
import { NotFoundContent } from "@client/components/common/404"
import { FeedIcon } from "@client/components/ui/feed-icon" import { FeedIcon } from "@client/components/ui/feed-icon"
import { openInFollowApp } from "@client/lib/helper" import { openInFollowApp } from "@client/lib/helper"
import { UrlBuilder } from "@client/lib/url-builder" 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 { useTitle } from "@follow/hooks"
import { cn } from "@follow/utils/utils" import { cn } from "@follow/utils/utils"
import type { SubscriptionWithFeed, UserProfile } from "@follow-app/client-sdk" import type { SubscriptionWithFeed, UserProfile } from "@follow-app/client-sdk"
import { FollowAPIError } from "@follow-app/client-sdk"
import * as React from "react" import * as React from "react"
import { Fragment, memo, useState } from "react" import { Fragment, memo, useState } from "react"
import { useParams } from "react-router" import { useParams } from "react-router"
@ -89,22 +91,43 @@ export const Component = () => {
useTitle(user.data?.name) 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 ( return (
<> <Fragment>
{user.isLoading ? ( <UserHero user={user.data} />
<LoadingCircle size="large" className="center fixed inset-0" /> <Lists userId={user.data.id} />
) : ( {/* Subscriptions Section */}
<Fragment> <Subscriptions userId={user.data.id} />
<UserHero user={user.data!} /> </Fragment>
<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 UserHero = ({ user }: { user: UserProfile }) => {
const subscriptions = useUserSubscriptionsQuery(user.id) 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 { getProviders } from "@client/lib/auth"
import { getHydrateData } from "@client/lib/helper" import { getHydrateData } from "@client/lib/helper"
import type { LoginHydrateData } from "@client/pages/(login)/login/metadata" import type { LoginHydrateData } from "@client/pages/(login)/login/metadata"
import { isBizId, sortByAlphabet } from "@follow/utils/utils" import { sortByAlphabet } from "@follow/utils/utils"
import type { import type {
InboxSubscriptionResponse, InboxSubscriptionResponse,
ListSubscriptionResponse, ListSubscriptionResponse,
@ -10,6 +10,8 @@ import type {
} from "@follow-app/client-sdk" } from "@follow-app/client-sdk"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { getUserProfile } from "../../src/lib/user-profile-params"
type GetUserSubscriptionsResponse = ( type GetUserSubscriptionsResponse = (
SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse
)[] )[]
@ -69,13 +71,7 @@ export const useUserSubscriptionsQuery = (userId: string | undefined) => {
} }
export const fetchUser = async (handleOrId: string | undefined) => { export const fetchUser = async (handleOrId: string | undefined) => {
const handle = isBizId(handleOrId || "") const res = await getUserProfile(followClient, handleOrId)
? handleOrId
: `${handleOrId}`.startsWith("@")
? `${handleOrId}`.slice(1)
: handleOrId
const res = await followClient.api.profiles.getProfile({ id: handleOrId, handle })
return res.data return res.data
} }

View File

@ -10,6 +10,7 @@
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts", "dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
"meta": "tsx helper/meta-map.ts --watch", "meta": "tsx helper/meta-map.ts --watch",
"start": "tsx index.ts", "start": "tsx index.ts",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View File

@ -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</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

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

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

@ -0,0 +1,19 @@
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,6 +9,7 @@ import { FetchError } from "ofetch"
import path, { dirname, resolve } from "pathe" import path, { dirname, resolve } from "pathe"
import xss from "xss" import xss from "xss"
import { injectHydrationScript } from "../lib/hydration-script"
import { NotFoundError } from "../lib/not-found" import { NotFoundError } from "../lib/not-found"
import { buildSeoMetaTags } from "../lib/seo" import { buildSeoMetaTags } from "../lib/seo"
import { injectMetaHandler, MetaError } from "../meta-handler" import { injectMetaHandler, MetaError } from "../meta-handler"
@ -151,13 +152,7 @@ async function injectMetaToTemplate(document: Document, req: FastifyRequest, res
break break
} }
case "hydrate": { case "hydrate": {
// Insert hydrate script 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 break
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1398,8 +1398,8 @@ importers:
specifier: 4.1.2 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) 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: react-native-uikit-colors:
specifier: 1.0.0 specifier: 0.6.2
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))) 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)))
react-native-volume-manager: react-native-volume-manager:
specifier: 2.0.8 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) 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,6 +10314,9 @@ packages:
os: [darwin] os: [darwin]
hasBin: true hasBin: true
apple-uikit-colors@0.6.2:
resolution: {integrity: sha512-sv0b92krbTZGNt4AJQLAPioUvuEI/wW2cLqGuEBWl3KpU2JK/odOftx8+dGSC5jGWhdB5qnwBqF96X2FA8FUQA==}
apple-uikit-colors@1.0.0: apple-uikit-colors@1.0.0:
resolution: {integrity: sha512-G2Ti2ogMOOC1phfHacSrLacDiE0RcLI7IG3aNoaw7Ack23WI8/9QczmUuhxxkU9BoIR2euCZ2G4lD5qJ+OknwA==} resolution: {integrity: sha512-G2Ti2ogMOOC1phfHacSrLacDiE0RcLI7IG3aNoaw7Ack23WI8/9QczmUuhxxkU9BoIR2euCZ2G4lD5qJ+OknwA==}
@ -16811,6 +16814,14 @@ packages:
shaka-player: shaka-player:
optional: true 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: react-native-uikit-colors@1.0.0:
resolution: {integrity: sha512-DEEc/OTrNNkIgfgbjSsG4pL0pvBaqtwmjN4FCITHQYeJFupxPOcGJehatMfPE6x3QItyIe45C0sYZz/ish+x6w==} resolution: {integrity: sha512-DEEc/OTrNNkIgfgbjSsG4pL0pvBaqtwmjN4FCITHQYeJFupxPOcGJehatMfPE6x3QItyIe45C0sYZz/ish+x6w==}
peerDependencies: peerDependencies:
@ -17902,6 +17913,9 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: ^4.0.0 tailwindcss: ^4.0.0
tailwindcss-uikit-colors@0.6.2:
resolution: {integrity: sha512-RfF3VVB2nHvJwPZ2ZSTMgKYbZjRny/KW3rP6ufg6/bopxINDQ9TpN/fJXxvKnD86hIU0F2oLIfAv2r6o/ZWcZw==}
tailwindcss-uikit-colors@1.0.0: tailwindcss-uikit-colors@1.0.0:
resolution: {integrity: sha512-18MGdMVSoXFKjcVkUIu7Q5USqdenAvgFJT9DEocPmdS94b8d/z3s0Uw8LvaNDINfDN9ls24hfg6/hyFqpcZpxw==} resolution: {integrity: sha512-18MGdMVSoXFKjcVkUIu7Q5USqdenAvgFJT9DEocPmdS94b8d/z3s0Uw8LvaNDINfDN9ls24hfg6/hyFqpcZpxw==}
@ -29534,6 +29548,8 @@ snapshots:
repeat-string: 1.6.1 repeat-string: 1.6.1
optional: true optional: true
apple-uikit-colors@0.6.2: {}
apple-uikit-colors@1.0.0: {} apple-uikit-colors@1.0.0: {}
archiver-utils@2.1.0: archiver-utils@2.1.0:
@ -37499,6 +37515,15 @@ snapshots:
react: 19.2.7 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: 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))): 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: dependencies:
apple-uikit-colors: 1.0.0 apple-uikit-colors: 1.0.0
@ -38886,6 +38911,10 @@ snapshots:
dependencies: dependencies:
tailwindcss: 4.3.2 tailwindcss: 4.3.2
tailwindcss-uikit-colors@0.6.2:
dependencies:
apple-uikit-colors: 0.6.2
tailwindcss-uikit-colors@1.0.0: tailwindcss-uikit-colors@1.0.0:
dependencies: dependencies:
apple-uikit-colors: 1.0.0 apple-uikit-colors: 1.0.0