Compare commits
44 Commits
desktop@1.
...
dev
| Author | SHA1 | Date |
|---|---|---|
|
|
3846c90b67 | |
|
|
3bc8336562 | |
|
|
a580e0a337 | |
|
|
f6777ee559 | |
|
|
6a8c07c000 | |
|
|
e291c3d1da | |
|
|
efafb15d7c | |
|
|
4dea1853b6 | |
|
|
07dd7e27ab | |
|
|
48ce50d034 | |
|
|
ec8838083e | |
|
|
0eb0b13dab | |
|
|
376e6e0343 | |
|
|
eb782e0389 | |
|
|
c8a0aa0b4f | |
|
|
e0c5d6186a | |
|
|
8a2babfd4f | |
|
|
2d9c026cca | |
|
|
a27db571b3 | |
|
|
dd2c933936 | |
|
|
bd91b015ee | |
|
|
2350884eae | |
|
|
52f587dacb | |
|
|
3d65478fb6 | |
|
|
22c8a667e6 | |
|
|
2034702df6 | |
|
|
764c70b48c | |
|
|
a9e2b1a818 | |
|
|
110960a6f0 | |
|
|
cf224d63f5 | |
|
|
773f1bfe21 | |
|
|
192a7139ce | |
|
|
6000787cb8 | |
|
|
c3eb7285f9 | |
|
|
d7365aebf4 | |
|
|
d90cdfe2b3 | |
|
|
713c53352d | |
|
|
c231c05ed9 | |
|
|
8f7471951d | |
|
|
57fd759f25 | |
|
|
d726ccccb2 | |
|
|
06135760ba | |
|
|
7c4d51a642 | |
|
|
c3fae852aa |
|
|
@ -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
|
||||
'''
|
||||
|
|
@ -7,13 +7,21 @@
|
|||
|
||||
import { execSync } from "node:child_process"
|
||||
import { appendFileSync } from "node:fs"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
// Configuration
|
||||
const RELEASE_PATTERNS = {
|
||||
desktop: /release\(desktop\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i,
|
||||
mobile: /release\(mobile\): 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-.]+)?)(?: \(#\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 = {
|
||||
SUCCESS: 0,
|
||||
GIT_ERROR: 2,
|
||||
|
|
@ -57,7 +65,6 @@ function setGitHubOutput(key, value) {
|
|||
|
||||
/**
|
||||
* Get the latest commit message
|
||||
* @returns {string} Latest commit message
|
||||
*/
|
||||
function getLatestCommitMessage() {
|
||||
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
|
||||
* @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) {
|
||||
for (const [platform, regex] of Object.entries(RELEASE_PATTERNS)) {
|
||||
const match = commitMessage.match(regex)
|
||||
if (match) {
|
||||
const version = match[1]
|
||||
const tagName = `${platform}/${version}`
|
||||
export function extractReleaseInfo(commitMessage, refName = process.env.GITHUB_REF_NAME) {
|
||||
const [commitSubject = "", ...commitBodyLines] = commitMessage.split(/\r?\n/)
|
||||
const expectedPlatform = refName ? RELEASE_PLATFORM_BY_REF[refName] : undefined
|
||||
|
||||
return {
|
||||
platform,
|
||||
version,
|
||||
tagName,
|
||||
if (refName && !expectedPlatform) {
|
||||
return null
|
||||
}
|
||||
|
||||
const platforms = expectedPlatform ? [expectedPlatform] : Object.keys(RELEASE_PATTERNS)
|
||||
const candidates = [commitSubject.trim()]
|
||||
|
||||
if (GITHUB_MERGE_SUBJECT_PATTERN.test(commitSubject)) {
|
||||
const pullRequestTitle = commitBodyLines.map((line) => line.trim()).find(Boolean)
|
||||
if (pullRequestTitle) {
|
||||
candidates.push(pullRequestTitle)
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const platform of platforms) {
|
||||
const match = candidate.match(RELEASE_PATTERNS[platform])
|
||||
if (match) {
|
||||
const version = match[1]
|
||||
const tagName = `${platform}/${version}`
|
||||
|
||||
return {
|
||||
platform,
|
||||
version,
|
||||
tagName,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -128,4 +159,6 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
main()
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
@ -407,7 +407,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
@ -148,7 +148,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
|
@ -52,5 +52,10 @@ jobs:
|
|||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=16384"
|
||||
npm exec turbo run format:check typecheck lint
|
||||
- name: Run release workflow tests
|
||||
run: >-
|
||||
pnpm exec vitest run
|
||||
.github/scripts/extract-release-info.test.ts
|
||||
.github/scripts/release-workflow-guards.test.ts
|
||||
- name: Run test
|
||||
run: npm exec turbo run test
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
|
|
@ -36,7 +36,6 @@ jobs:
|
|||
- name: Extract release information
|
||||
id: extract_info
|
||||
run: .github/scripts/extract-release-info.mjs
|
||||
continue-on-error: true
|
||||
|
||||
- name: Expose release outputs
|
||||
id: release_info
|
||||
|
|
|
|||
|
|
@ -37,8 +37,11 @@ apps/desktop/resources/cli
|
|||
|
||||
.wrangler
|
||||
|
||||
# Local agent artifacts
|
||||
.codex/
|
||||
# Local agent artifacts, except the shared Codex environment
|
||||
.codex/*
|
||||
!.codex/environments/
|
||||
.codex/environments/*
|
||||
!.codex/environments/environment.toml
|
||||
|
||||
# E2E outputs
|
||||
/apps/desktop/e2e/playwright-report/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
# Files copied by Codex into managed worktrees
|
||||
apps/desktop/.env
|
||||
|
|
@ -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
|
||||
|
|
@ -36,7 +36,43 @@ const ymlMapsMap = {
|
|||
win32: "latest.yml",
|
||||
}
|
||||
|
||||
const keepModules = new Set(["font-list", "vscode-languagedetection"])
|
||||
// Keep external runtime modules and their production dependency trees in app.asar.
|
||||
// Scoped packages are copied as a whole because cleanSources operates on top-level entries.
|
||||
const keepModules = new Set([
|
||||
"@asamuzakjp",
|
||||
"@bramus",
|
||||
"@csstools",
|
||||
"@exodus",
|
||||
"bidi-js",
|
||||
"css-tree",
|
||||
"data-urls",
|
||||
"decimal.js",
|
||||
"entities",
|
||||
"font-list",
|
||||
"html-encoding-sniffer",
|
||||
"is-potential-custom-element-name",
|
||||
"jsdom",
|
||||
"lru-cache",
|
||||
"mdn-data",
|
||||
"parse5",
|
||||
"punycode",
|
||||
"require-from-string",
|
||||
"saxes",
|
||||
"source-map-js",
|
||||
"symbol-tree",
|
||||
"tldts",
|
||||
"tldts-core",
|
||||
"tough-cookie",
|
||||
"tr46",
|
||||
"undici",
|
||||
"vscode-languagedetection",
|
||||
"w3c-xmlserializer",
|
||||
"webidl-conversions",
|
||||
"whatwg-mimetype",
|
||||
"whatwg-url",
|
||||
"xml-name-validator",
|
||||
"xmlchars",
|
||||
])
|
||||
const keepLanguages = new Set(["en", "en_GB", "en-US", "en_US"])
|
||||
|
||||
// remove folders & files not to be included in the app
|
||||
|
|
@ -93,7 +129,10 @@ async function cleanSources(buildPath, _electronVersion, platform, _arch, callba
|
|||
|
||||
const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback()
|
||||
|
||||
const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`)
|
||||
const keepModulePattern = [...keepModules]
|
||||
.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("|")
|
||||
const ignorePattern = new RegExp(`^/node_modules/(?!(?:${keepModulePattern})(?:/|$))`)
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import fsp from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
|
||||
import { shell } from "electron"
|
||||
import path from "pathe"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { IntegrationService } from "./integration"
|
||||
|
||||
|
|
@ -69,4 +70,70 @@ describe("IntegrationService", () => {
|
|||
fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1")),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
describe("openURLScheme", () => {
|
||||
const openExternalMock = vi.mocked(shell.openExternal)
|
||||
|
||||
beforeEach(() => {
|
||||
openExternalMock.mockReset()
|
||||
openExternalMock.mockResolvedValue()
|
||||
})
|
||||
|
||||
it("rejects input that cannot be parsed as a URL", async () => {
|
||||
const service = new IntegrationService()
|
||||
|
||||
await expect(service.openURLScheme("not-a-url")).rejects.toThrow(/Invalid URL scheme/i)
|
||||
expect(openExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// These are the dangerous protocols that previously slipped through the
|
||||
// "contains ://" guard and reached shell.openExternal verbatim.
|
||||
// shell.openExternal docs explicitly warn that passing untrusted URLs is
|
||||
// unsafe — file://, smb://, search-ms:, ms-msdt:, jar:, res:, etc. have
|
||||
// been used in real-world RCE / NTLM-credential-theft chains.
|
||||
it.each([
|
||||
["file:///etc/passwd"],
|
||||
["FILE:///etc/passwd"],
|
||||
["smb://attacker.example/share"],
|
||||
["jar:http://attacker.example/x.jar!/"],
|
||||
["res://shell32.dll/1"],
|
||||
["ms-msdt:/id PCWDiagnostic"],
|
||||
["search-ms:query=secret"],
|
||||
["javascript:alert(1)"],
|
||||
["data:text/html,<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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
static override readonly groupName = "integration"
|
||||
|
||||
|
|
@ -382,11 +405,32 @@ ${content}
|
|||
const requestId = Math.random().toString(36).slice(2, 8)
|
||||
|
||||
try {
|
||||
// Validate URL scheme format
|
||||
if (!scheme.includes("://")) {
|
||||
// Parse and validate the protocol up-front. `shell.openExternal` will
|
||||
// happily dispatch any scheme the OS has registered a handler for,
|
||||
// including `file://`, `smb://`, `ms-msdt:`, `search-ms:`, `jar:`,
|
||||
// `res:`, etc. Several of those have well-documented exploit chains
|
||||
// (NTLM credential theft over SMB, MSDT/Follina RCE on Windows,
|
||||
// local-file disclosure via file://). The Electron docs explicitly
|
||||
// warn against passing untrusted URLs to `shell.openExternal`, so we
|
||||
// enforce a strict allowlist of schemes that the integrations UI is
|
||||
// intended to support.
|
||||
let protocol: string
|
||||
try {
|
||||
protocol = new URL(scheme).protocol.replace(/:$/, "").toLowerCase()
|
||||
} catch {
|
||||
throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')")
|
||||
}
|
||||
|
||||
if (!protocol) {
|
||||
throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')")
|
||||
}
|
||||
|
||||
if (!isAllowedURLSchemeProtocol(protocol)) {
|
||||
throw new Error(
|
||||
`URL scheme "${protocol}://" is not allowed. Allowed schemes: ${[...ALLOWED_URL_SCHEME_PROTOCOLS].sort().join(", ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Log URL scheme execution (mask sensitive data)
|
||||
const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>
|
||||
// Mask potential sensitive query parameters
|
||||
|
|
@ -399,7 +443,7 @@ ${content}
|
|||
|
||||
logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {
|
||||
scheme: safeScheme,
|
||||
protocol: scheme.split("://")[0],
|
||||
protocol,
|
||||
})
|
||||
|
||||
// Use Electron's shell.openExternal to open URL scheme
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { createAtomHooks, jotaiStore } from "~/lib/jotai"
|
|||
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
|
||||
/** Custom z-index for popover */
|
||||
zIndex?: number
|
||||
/** Whether the popover should close when clicked outside */
|
||||
/** Whether the popover should use modal focus and pointer behavior */
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +33,11 @@ export const showPopover = (
|
|||
element: ReactNode,
|
||||
props?: PopoverProps,
|
||||
) => {
|
||||
const currentPopover = jotaiStore.get(popoverAtom)
|
||||
if (currentPopover.open) {
|
||||
currentPopover.abortController.abort()
|
||||
}
|
||||
|
||||
jotaiStore.set(popoverAtom, {
|
||||
open: true,
|
||||
position: mouseXY,
|
||||
|
|
@ -41,3 +46,11 @@ export const showPopover = (
|
|||
abortController: new AbortController(),
|
||||
})
|
||||
}
|
||||
|
||||
export const dismissPopover = () => {
|
||||
const currentPopover = jotaiStore.get(popoverAtom)
|
||||
if (!currentPopover.open) return
|
||||
|
||||
currentPopover.abortController.abort()
|
||||
jotaiStore.set(popoverAtom, { open: false })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import { useCallback } from "react"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { dismissPopover } from "~/atoms/popover"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
|
|
@ -140,11 +141,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
}
|
||||
dismissPopover()
|
||||
} catch {
|
||||
// If sharing fails, copy link as fallback
|
||||
try {
|
||||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
dismissPopover()
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
}
|
||||
|
|
@ -156,6 +159,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
try {
|
||||
await copyToClipboard(shareUrl)
|
||||
toast.success(t("share.link_copied"))
|
||||
dismissPopover()
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
}
|
||||
|
|
@ -178,6 +182,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
.replace("{text}", shareText)
|
||||
|
||||
window.open(finalUrl, "_blank", "width=600,height=400")
|
||||
dismissPopover()
|
||||
},
|
||||
[entryId, generateShareContent],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"
|
|||
import type { TFunction } from "i18next"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
|
||||
import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs"
|
||||
|
|
@ -17,6 +18,15 @@ import { followClient } from "~/lib/api-client"
|
|||
import { subscription } from "~/lib/auth"
|
||||
|
||||
const APPLE_SUBSCRIPTION_MANAGEMENT_URL = "https://apps.apple.com/account/subscriptions"
|
||||
const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS"
|
||||
|
||||
type BillingPortalResponse = {
|
||||
code: number
|
||||
data?: {
|
||||
url: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ActiveSubscription = {
|
||||
source: "stripe" | "apple" | null
|
||||
|
|
@ -83,6 +93,29 @@ const formatFeatureValue = (
|
|||
return value
|
||||
}
|
||||
|
||||
const openStripeBillingPortal = async () => {
|
||||
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href
|
||||
const res = await fetch(`${env.VITE_API_URL}/billing/portal`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ returnUrl }),
|
||||
})
|
||||
const data = (await res.json()) as BillingPortalResponse
|
||||
if (!res.ok || data.code !== 0 || !data.data?.url) {
|
||||
throw new Error(data.message || "Failed to open billing portal")
|
||||
}
|
||||
|
||||
if (IN_ELECTRON) {
|
||||
window.open(data.data.url, "_blank")
|
||||
return
|
||||
}
|
||||
|
||||
window.location.assign(data.data.url)
|
||||
}
|
||||
|
||||
const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: boolean }) => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
|
|
@ -97,10 +130,20 @@ const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: bo
|
|||
cancelUrl: env.VITE_WEB_URL,
|
||||
disableRedirect: IN_ELECTRON,
|
||||
})
|
||||
if (res.error?.code === ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE) {
|
||||
await openStripeBillingPortal()
|
||||
return
|
||||
}
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message)
|
||||
}
|
||||
if (IN_ELECTRON && res.data?.url) {
|
||||
window.open(res.data.url, "_blank")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -120,20 +163,9 @@ const useActiveSubscription = () => {
|
|||
|
||||
const useBillingPortal = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href
|
||||
const res = await fetch(`${env.VITE_API_URL}/billing/portal`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ returnUrl }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data?.url) {
|
||||
window.open(data.data.url, "_blank")
|
||||
}
|
||||
mutationFn: openStripeBillingPortal,
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ function FeedCategoryImpl({
|
|||
onClick={handleCollapseButtonClick}
|
||||
data-state={open ? "open" : "close"}
|
||||
className={cn(
|
||||
"flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90",
|
||||
"flex h-8 items-center data-[state=open]:[&_.i-mgc-right-cute-fi]:rotate-90",
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -8,9 +8,9 @@ import {
|
|||
PopoverTrigger,
|
||||
} from "@follow/components/ui/popover/index.jsx"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo, useEffect, useRef } from "react"
|
||||
import { memo, useEffect } from "react"
|
||||
|
||||
import { usePopoverState } from "~/atoms/popover"
|
||||
import { dismissPopover, usePopoverValue } from "~/atoms/popover"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
|
||||
export const PopoverProvider: Component = ({ children }) => (
|
||||
|
|
@ -21,36 +21,31 @@ export const PopoverProvider: Component = ({ children }) => (
|
|||
)
|
||||
|
||||
const Handler = memo(() => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
const [popoverState, setPopoverState] = usePopoverState()
|
||||
const popoverState = usePopoverValue()
|
||||
const setGlobalFocusableScope = useSetGlobalFocusableScope()
|
||||
|
||||
useEffect(() => {
|
||||
if (!popoverState.open) return
|
||||
const triggerElement = ref.current
|
||||
if (!triggerElement) return
|
||||
|
||||
triggerElement.dispatchEvent(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}, [popoverState])
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
||||
return () => {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
||||
}
|
||||
}, [popoverState.open, setGlobalFocusableScope])
|
||||
|
||||
const { modal, zIndex, ...contentProps } = popoverState.open ? (popoverState.props ?? {}) : {}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popoverState.open}
|
||||
modal={modal}
|
||||
onOpenChange={(state) => {
|
||||
if (state) {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
||||
} else {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
||||
setPopoverState({ open: false })
|
||||
if (!state) {
|
||||
dismissPopover()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
ref={ref}
|
||||
className="pointer-events-none"
|
||||
style={
|
||||
popoverState.open
|
||||
|
|
@ -58,9 +53,14 @@ const Handler = memo(() => {
|
|||
: {}
|
||||
}
|
||||
/>
|
||||
<PopoverContent asChild forceMount>
|
||||
<AnimatePresence>
|
||||
{popoverState.open && (
|
||||
<AnimatePresence>
|
||||
{popoverState.open && (
|
||||
<PopoverContent
|
||||
{...contentProps}
|
||||
asChild
|
||||
forceMount
|
||||
style={{ ...contentProps.style, zIndex }}
|
||||
>
|
||||
<m.div
|
||||
className="mr-2 rounded-xl border bg-material-ultra-thick p-2 shadow-2xl backdrop-blur-background"
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
|
|
@ -71,9 +71,9 @@ const Handler = memo(() => {
|
|||
<PopoverArrow className="fill-border" />
|
||||
{popoverState.content}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</PopoverContent>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "Folo",
|
||||
"type": "module",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"description": "Follow everything in one place",
|
||||
"author": "Folo Team",
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
"workbox-build": "7.4.1",
|
||||
"workbox-window": "7.4.1"
|
||||
},
|
||||
"runtimeVersion": "1.11.0",
|
||||
"runtimeVersion": "1.12.0",
|
||||
"productName": "Folo",
|
||||
"mainHash": "b672f321b6478cbaad0ad6e354d73ca3c8a79f90aaadb41c119499639ec66e87"
|
||||
"mainHash": "0fdc4db0cd19e2c2d6cf6c596aa1a62cc253b4c9612633470cb6b8d64fba403a"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"mode": "build",
|
||||
"runtimeVersion": null,
|
||||
"channel": null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fs from "node:fs"
|
||||
|
||||
import yaml from "js-yaml"
|
||||
import * as yaml from "js-yaml"
|
||||
import path from "pathe"
|
||||
|
||||
const outDir = "./out/make"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import crypto from "node:crypto"
|
|||
import fs from "node:fs"
|
||||
import { fileURLToPath, resolve } from "node:url"
|
||||
|
||||
import yaml from "js-yaml"
|
||||
import * as yaml from "js-yaml"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/")
|
||||
|
|
|
|||
|
|
@ -111,6 +111,17 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||
},
|
||||
android: {
|
||||
package: "is.follow",
|
||||
// Media selection uses system pickers; saving only needs write access on older Android versions.
|
||||
blockedPermissions: [
|
||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_AUDIO",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
],
|
||||
adaptiveIcon: {
|
||||
foregroundImage: adaptiveIconPath,
|
||||
monochromeImage: adaptiveIconPath,
|
||||
|
|
@ -158,7 +169,8 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||
{
|
||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
|
||||
isAccessMediaLocationEnabled: true,
|
||||
isAccessMediaLocationEnabled: false,
|
||||
granularPermissions: [],
|
||||
},
|
||||
],
|
||||
"expo-apple-authentication",
|
||||
|
|
@ -192,6 +204,8 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||
"expo-image-picker",
|
||||
{
|
||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
cameraPermission: false,
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
# What's New in vNEXT_VERSION
|
||||
|
||||
## Shiny new things
|
||||
|
||||
## Improvements
|
||||
|
||||
- Upgraded the app to Expo SDK 57, React Native 0.86, and updated native integrations
|
||||
|
||||
## No longer broken
|
||||
|
||||
- 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
|
||||
## Thanks
|
||||
|
||||
Special thanks to volunteer contributors @ for their valuable contributions
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.5</string>
|
||||
<string>0.5.8</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>8</string>
|
||||
<string>11</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@follow/mobile",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.8",
|
||||
"private": true,
|
||||
"main": "src/main.tsx",
|
||||
"scripts": {
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
"react-native-sheet-transitions": "0.1.2",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-track-player": "4.1.2",
|
||||
"react-native-uikit-colors": "1.0.0",
|
||||
"react-native-uikit-colors": "0.6.2",
|
||||
"react-native-volume-manager": "2.0.8",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-native-webview": "14.0.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": "0.5.5",
|
||||
"mode": "ota",
|
||||
"runtimeVersion": "0.5.0",
|
||||
"channel": "production"
|
||||
"version": "0.5.8",
|
||||
"mode": "store",
|
||||
"runtimeVersion": null,
|
||||
"channel": null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveRuntimeVersion } from "../app.config.base"
|
||||
import createExpoConfig, { resolveRuntimeVersion } from "../app.config.base"
|
||||
|
||||
describe("resolveRuntimeVersion", () => {
|
||||
it("keeps the development runtime version stable", () => {
|
||||
|
|
@ -33,3 +33,42 @@ describe("resolveRuntimeVersion", () => {
|
|||
).toThrow(/OTA_RUNTIME_VERSION/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Android media permissions", () => {
|
||||
const config = createExpoConfig({
|
||||
config: {},
|
||||
} as Parameters<typeof createExpoConfig>[0])
|
||||
|
||||
it("blocks broad media access that is not required by the app", () => {
|
||||
expect(config.android?.blockedPermissions).toEqual([
|
||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_AUDIO",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
])
|
||||
})
|
||||
|
||||
it("configures media APIs for picker and write-only access", () => {
|
||||
expect(config.plugins).toContainEqual([
|
||||
"expo-media-library",
|
||||
{
|
||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
|
||||
isAccessMediaLocationEnabled: false,
|
||||
granularPermissions: [],
|
||||
},
|
||||
])
|
||||
expect(config.plugins).toContainEqual([
|
||||
"expo-image-picker",
|
||||
{
|
||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
cameraPermission: false,
|
||||
microphonePermission: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,7 @@ import { IMAGE_PROXY_URL } from "@follow/utils/img-proxy"
|
|||
import ImageEditor from "@react-native-community/image-editor"
|
||||
import * as FileSystem from "expo-file-system/legacy"
|
||||
import type { ImageProps, ImageSource } from "expo-image"
|
||||
import { saveToLibraryAsync, usePermissions } from "expo-media-library"
|
||||
import { Asset, usePermissions } from "expo-media-library"
|
||||
import * as Sharing from "expo-sharing"
|
||||
import { useCallback } from "react"
|
||||
import { Image } from "react-native"
|
||||
|
|
@ -145,7 +145,7 @@ export const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => {
|
|||
const croppedImage = await getImageData(uri)
|
||||
const filename = `${extractFilenameFromUrl(uri)}.png`
|
||||
const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename)
|
||||
await saveToLibraryAsync(filePath)
|
||||
await Asset.create(filePath)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ export const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => {
|
|||
*/
|
||||
export function useSaveImageToMediaLibrary() {
|
||||
const [permissionResponse, requestPermission, getPermission] = usePermissions({
|
||||
granularPermissions: ["photo"],
|
||||
writeOnly: true,
|
||||
})
|
||||
return useCallback(
|
||||
async (uri: string) => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import { FetchError } from "ofetch"
|
|||
import { getIsPaymentEnabled } from "@/src/atoms/server-configs"
|
||||
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
|
||||
|
||||
import { sanitizeErrorMessage } from "./error-message"
|
||||
import { toast } from "./toast"
|
||||
|
||||
export const getFetchErrorInfo = (
|
||||
|
|
@ -21,11 +22,11 @@ export const getFetchErrorInfo = (
|
|||
const i18nKey = `errors:${code}` as any
|
||||
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
|
||||
return {
|
||||
message: `${i18nMessage}${reason ? `: ${reason}` : ""}`,
|
||||
message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`),
|
||||
code,
|
||||
}
|
||||
} catch {
|
||||
return { message: error.message }
|
||||
return { message: sanitizeErrorMessage(error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,15 +36,15 @@ export const getFetchErrorInfo = (
|
|||
const i18nKey = `errors:${code}` as any
|
||||
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
|
||||
return {
|
||||
message: i18nMessage,
|
||||
message: sanitizeErrorMessage(i18nMessage),
|
||||
code,
|
||||
}
|
||||
} catch {
|
||||
return { message: error.message }
|
||||
return { message: sanitizeErrorMessage(error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
return { message: error.message }
|
||||
return { message: sanitizeErrorMessage(error.message) }
|
||||
}
|
||||
|
||||
export const getFetchErrorMessage = (error: Error) => {
|
||||
|
|
@ -58,7 +59,7 @@ export const createErrorToaster = (title?: string) => (err: Error) =>
|
|||
toastFetchError(err, { title })
|
||||
|
||||
export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {
|
||||
const { message: fallbackMessage } = error
|
||||
const fallbackMessage = sanitizeErrorMessage(error.message)
|
||||
let message = fallbackMessage
|
||||
let _reason = ""
|
||||
let code: number | undefined
|
||||
|
|
@ -113,6 +114,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
|||
}
|
||||
}
|
||||
|
||||
message = sanitizeErrorMessage(message)
|
||||
|
||||
// 2fa errors are handled by the form
|
||||
if (code === 4007 || code === 4008) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const PLAN_FEATURE_ORDER: Array<keyof PaymentFeature> = [
|
|||
]
|
||||
|
||||
const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"]
|
||||
const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS"
|
||||
|
||||
type SegmentLayout = {
|
||||
width: number
|
||||
|
|
@ -110,6 +111,14 @@ type ActiveSubscription = {
|
|||
canManage: boolean
|
||||
}
|
||||
|
||||
type BillingPortalResponse = {
|
||||
code: number
|
||||
data?: {
|
||||
url: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
const currencyFormatter = (() => {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
|
|
@ -425,6 +434,25 @@ export const PlanScreen: NavigationControllerView = () => {
|
|||
return Math.round(total / paidPlans.length)
|
||||
}, [sortedPlans])
|
||||
|
||||
const openStripeBillingPortal = useCallback(async () => {
|
||||
const data = await followClient.request<BillingPortalResponse>("/billing/portal", {
|
||||
method: "POST",
|
||||
body: { returnUrl: proxyEnv.WEB_URL },
|
||||
})
|
||||
if (data.code !== 0 || !data.data?.url) {
|
||||
throw new Error(data.message || t("subscription.actions.manage_error"))
|
||||
}
|
||||
|
||||
await openURL(data.data.url)
|
||||
}, [t])
|
||||
|
||||
const billingPortalMutation = useMutation({
|
||||
mutationFn: openStripeBillingPortal,
|
||||
onError: () => {
|
||||
toast.error(t("subscription.actions.manage_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const upgradeMutation = useMutation<void, Error, UpgradeVariables>({
|
||||
mutationFn: async ({ planId, annual }) => {
|
||||
const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId)
|
||||
|
|
@ -452,6 +480,13 @@ export const PlanScreen: NavigationControllerView = () => {
|
|||
cancelUrl: proxyEnv.WEB_URL,
|
||||
disableRedirect: true,
|
||||
})
|
||||
if (response.error?.code === ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE) {
|
||||
await openStripeBillingPortal()
|
||||
return
|
||||
}
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
|
||||
const redirectUrl =
|
||||
typeof response === "object" && response && "data" in response && response.data
|
||||
|
|
@ -468,24 +503,6 @@ export const PlanScreen: NavigationControllerView = () => {
|
|||
},
|
||||
})
|
||||
|
||||
const billingPortalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const data = await followClient.request<{ code: number; data?: { url: string } }>(
|
||||
"/billing/portal",
|
||||
{
|
||||
method: "POST",
|
||||
body: { returnUrl: proxyEnv.WEB_URL },
|
||||
},
|
||||
)
|
||||
if (data.code === 0 && data.data?.url) {
|
||||
await openURL(data.data.url)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("subscription.actions.manage_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const handleManageSubscription = useCallback(() => {
|
||||
billingPortalMutation.mutate()
|
||||
}, [billingPortalMutation])
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ import { proxyEnv } from "@/src/lib/proxy-env"
|
|||
import { queryClient } from "@/src/lib/query-client"
|
||||
import { toast } from "@/src/lib/toast"
|
||||
|
||||
import {
|
||||
buildAppleVerificationRequest,
|
||||
isKnownAppleSubscriptionPurchase,
|
||||
selectSignedTransactionInfo,
|
||||
} from "./apple-iap-purchase"
|
||||
|
||||
const billingSubscriptionQueryKey = ["billingSubscription"]
|
||||
|
||||
type BillingSubscriptionResponse = {
|
||||
|
|
@ -123,16 +129,23 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
|||
|
||||
const verifyPurchase = useCallback(
|
||||
async (purchase: Purchase) => {
|
||||
const productId = purchase.id
|
||||
const jwsRepresentation =
|
||||
purchase.purchaseToken ||
|
||||
(await getTransactionJwsIOS(productId).catch(() => null)) ||
|
||||
(await validateReceipt({ apple: { sku: productId } })
|
||||
.then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined))
|
||||
.catch(() => {}))
|
||||
const productId = purchase.productId
|
||||
let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken)
|
||||
|
||||
if (!jwsRepresentation) {
|
||||
throw new Error(t("subscription.actions.upgrade_error"))
|
||||
if (!signedTransactionInfo) {
|
||||
signedTransactionInfo = selectSignedTransactionInfo(
|
||||
await getTransactionJwsIOS(productId).catch(() => null),
|
||||
)
|
||||
}
|
||||
|
||||
if (!signedTransactionInfo) {
|
||||
signedTransactionInfo = selectSignedTransactionInfo(
|
||||
await validateReceipt({ apple: { sku: productId } })
|
||||
.then((result) =>
|
||||
"jwsRepresentation" in result ? result.jwsRepresentation : undefined,
|
||||
)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
}
|
||||
|
||||
const response = await followClient.request<{
|
||||
|
|
@ -140,23 +153,21 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
|||
data: BillingSubscriptionResponse
|
||||
}>("/billing/apple/verify", {
|
||||
method: "POST",
|
||||
body: {
|
||||
signedTransactionInfo: jwsRepresentation,
|
||||
},
|
||||
body: buildAppleVerificationRequest(purchase, signedTransactionInfo),
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error("Failed to verify Apple subscription")
|
||||
}
|
||||
},
|
||||
[t, validateReceipt],
|
||||
[validateReceipt],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
Platform.OS !== "ios" ||
|
||||
!currentPurchase ||
|
||||
!knownSubscriptionIds.has(currentPurchase.id)
|
||||
!isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
@ -280,7 +291,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>
|
||||
knownSubscriptionIds.has(purchase.id),
|
||||
isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds),
|
||||
)
|
||||
|
||||
if (restoredPurchases.length === 0) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -7,7 +7,7 @@ import { m, useAnimationControls } from "motion/react"
|
|||
import { Fragment, useEffect, useState } from "react"
|
||||
import * as React from "react"
|
||||
|
||||
const NotFoundContent = () => {
|
||||
export const NotFoundContent = () => {
|
||||
const [glitchText, setGlitchText] = useState("404")
|
||||
const [isGlitching, setIsGlitching] = useState(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { NotFoundContent } from "@client/components/common/404"
|
||||
import { FeedIcon } from "@client/components/ui/feed-icon"
|
||||
import { openInFollowApp } from "@client/lib/helper"
|
||||
import { UrlBuilder } from "@client/lib/url-builder"
|
||||
|
|
@ -10,6 +11,7 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
|||
import { useTitle } from "@follow/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { SubscriptionWithFeed, UserProfile } from "@follow-app/client-sdk"
|
||||
import { FollowAPIError } from "@follow-app/client-sdk"
|
||||
import * as React from "react"
|
||||
import { Fragment, memo, useState } from "react"
|
||||
import { useParams } from "react-router"
|
||||
|
|
@ -89,22 +91,43 @@ export const Component = () => {
|
|||
|
||||
useTitle(user.data?.name)
|
||||
|
||||
if (user.isLoading) {
|
||||
return <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 (
|
||||
<>
|
||||
{user.isLoading ? (
|
||||
<LoadingCircle size="large" className="center fixed inset-0" />
|
||||
) : (
|
||||
<Fragment>
|
||||
<UserHero user={user.data!} />
|
||||
<Lists userId={user.data?.id} />
|
||||
{/* Subscriptions Section */}
|
||||
<Subscriptions userId={user.data?.id} />
|
||||
</Fragment>
|
||||
)}
|
||||
</>
|
||||
<Fragment>
|
||||
<UserHero user={user.data} />
|
||||
<Lists userId={user.data.id} />
|
||||
{/* Subscriptions Section */}
|
||||
<Subscriptions userId={user.data.id} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileLoadError = ({ onRetry }: { onRetry: () => void }) => (
|
||||
<div className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-6 text-center">
|
||||
<i className="i-mgc-warning-fill mb-6 size-12 text-orange-500" />
|
||||
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Unable to load this profile
|
||||
</h1>
|
||||
<p className="mt-3 text-zinc-500 dark:text-zinc-400">
|
||||
This may be a temporary problem. Please try again.
|
||||
</p>
|
||||
<Button buttonClassName="mt-8" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UserHero = ({ user }: { user: UserProfile }) => {
|
||||
const subscriptions = useUserSubscriptionsQuery(user.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { followClient } from "@client/lib/api-fetch"
|
|||
import { getProviders } from "@client/lib/auth"
|
||||
import { getHydrateData } from "@client/lib/helper"
|
||||
import type { LoginHydrateData } from "@client/pages/(login)/login/metadata"
|
||||
import { isBizId, sortByAlphabet } from "@follow/utils/utils"
|
||||
import { sortByAlphabet } from "@follow/utils/utils"
|
||||
import type {
|
||||
InboxSubscriptionResponse,
|
||||
ListSubscriptionResponse,
|
||||
|
|
@ -10,6 +10,8 @@ import type {
|
|||
} from "@follow-app/client-sdk"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import { getUserProfile } from "../../src/lib/user-profile-params"
|
||||
|
||||
type GetUserSubscriptionsResponse = (
|
||||
SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse
|
||||
)[]
|
||||
|
|
@ -69,13 +71,7 @@ export const useUserSubscriptionsQuery = (userId: string | undefined) => {
|
|||
}
|
||||
|
||||
export const fetchUser = async (handleOrId: string | undefined) => {
|
||||
const handle = isBizId(handleOrId || "")
|
||||
? handleOrId
|
||||
: `${handleOrId}`.startsWith("@")
|
||||
? `${handleOrId}`.slice(1)
|
||||
: handleOrId
|
||||
|
||||
const res = await followClient.api.profiles.getProfile({ id: handleOrId, handle })
|
||||
const res = await getUserProfile(followClient, handleOrId)
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
|
||||
"meta": "tsx helper/meta-map.ts --watch",
|
||||
"start": "tsx index.ts",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
|
|
@ -9,6 +9,7 @@ import { FetchError } from "ofetch"
|
|||
import path, { dirname, resolve } from "pathe"
|
||||
import xss from "xss"
|
||||
|
||||
import { injectHydrationScript } from "../lib/hydration-script"
|
||||
import { NotFoundError } from "../lib/not-found"
|
||||
import { buildSeoMetaTags } from "../lib/seo"
|
||||
import { injectMetaHandler, MetaError } from "../meta-handler"
|
||||
|
|
@ -151,13 +152,7 @@ async function injectMetaToTemplate(document: Document, req: FastifyRequest, res
|
|||
break
|
||||
}
|
||||
case "hydrate": {
|
||||
// Insert hydrate script
|
||||
const script = document.createElement("script")
|
||||
script.innerHTML = `
|
||||
window.__HYDRATE__ = window.__HYDRATE__ || {}
|
||||
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
|
||||
`
|
||||
document.head.append(script)
|
||||
injectHydrationScript(document, meta.key, meta.data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ export const ogRoute = (app: FastifyInstance) => {
|
|||
|
||||
const createErrorFallback = (reply: FastifyReply) => (code: number | 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
|
||||
}
|
||||
let message = "Internal server error"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
import { isBizId } from "@follow/utils/utils"
|
||||
import type { FollowClient } from "@follow-app/client-sdk"
|
||||
import * as React from "react"
|
||||
|
||||
import { renderToImage } from "../../lib/og/render-to-image"
|
||||
import { getUserProfile } from "../../lib/user-profile-params"
|
||||
import { getImageBase64, OGAvatar, OGCanvas } from "./__base"
|
||||
|
||||
export const renderUserOG = async (apiClient: FollowClient, id: string) => {
|
||||
const handle = isBizId(id || "") ? id : `${id}`.startsWith("@") ? `${id}`.slice(1) : id
|
||||
|
||||
const user = await apiClient.api.profiles.getProfile({
|
||||
id,
|
||||
handle,
|
||||
})
|
||||
export const renderUserOG = async (apiClient: FollowClient, handleOrId: string) => {
|
||||
const user = await getUserProfile(apiClient, handleOrId)
|
||||
|
||||
if (!user) {
|
||||
throw 404
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"./client/**/*.tsx",
|
||||
"./types/**/*.d.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"./tailwind.config.ts",
|
||||
"./helper/**/*.ts"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineProject } from "vitest/config"
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
})
|
||||
|
|
@ -12,6 +12,7 @@ import xss from "xss"
|
|||
import resvgWasm from "./resvg.wasm"
|
||||
// OG image rendering
|
||||
import { createFollowClient } from "./src/lib/api-client"
|
||||
import { injectHydrationScript } from "./src/lib/hydration-script"
|
||||
import { NotFoundError } from "./src/lib/not-found"
|
||||
import { setFontsBucket } from "./src/lib/og/fonts.worker"
|
||||
import { setWasmModule } from "./src/lib/og/resvg-wasm-shim"
|
||||
|
|
@ -127,7 +128,7 @@ app.get("/og/:type/:id", async (c) => {
|
|||
return c.text(e === 404 ? "Not found" : "Internal server error", e)
|
||||
}
|
||||
console.error("OG render error:", e)
|
||||
return c.text(e?.message || "Internal server error", 500)
|
||||
return c.text("Internal server error", 500)
|
||||
}
|
||||
|
||||
if (!imageRes) {
|
||||
|
|
@ -298,12 +299,7 @@ async function injectMetaToTemplate(document: Document, c: any) {
|
|||
break
|
||||
}
|
||||
case "hydrate": {
|
||||
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)
|
||||
injectHydrationScript(document, meta.key, meta.data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
"name": "folo-ssr",
|
||||
"main": "dist/worker/worker-entry.mjs",
|
||||
"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": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "Feed failed to parse",
|
||||
"2010": "Ownership challenge failed",
|
||||
"2011": "Subscription limit exceeded",
|
||||
"2012": "RSSHub feed subscription limit exceeded",
|
||||
"3000": "Entry not found",
|
||||
"4000": "Already claimed",
|
||||
"4001": "User wallet error",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "Échec de l'analyse du flux",
|
||||
"2010": "Échec de la vérification de propriété",
|
||||
"2011": "Limite d'abonnement dépassée",
|
||||
"2012": "Limite d'abonnement aux flux RSSHub dépassée",
|
||||
"3000": "Entrée introuvable",
|
||||
"4000": "Déjà réclamé",
|
||||
"4001": "Erreur de portefeuille utilisateur",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "フィードの解析に失敗しました",
|
||||
"2010": "所有権の確認に失敗しました",
|
||||
"2011": "購読制限を超えました",
|
||||
"2012": "RSSHubフィードの購読数制限を超えました",
|
||||
"3000": "エントリが見つかりません",
|
||||
"4000": "すでに請求されています",
|
||||
"4001": "ユーザーウォレットエラー",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "订阅源解析失败",
|
||||
"2010": "所有权挑战失败",
|
||||
"2011": "超出订阅限制",
|
||||
"2012": "RSSHub 订阅源数量限制已超出",
|
||||
"3000": "未找到条目",
|
||||
"4000": "已被认领",
|
||||
"4001": "用户钱包错误",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "訂閱源解析失敗",
|
||||
"2010": "所有權挑戰失敗",
|
||||
"2011": "超過訂閱限制",
|
||||
"2012": "RSSHub 訂閱源數量限制已超過",
|
||||
"3000": "條目未找到",
|
||||
"4000": "已被認領",
|
||||
"4001": "用戶錢包錯誤",
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
}
|
||||
|
||||
[data-theme="dark"] .kbd {
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border: 1px solid hsl(var(--fo-border) / 0.5);
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.12),
|
||||
0 1px 4px rgba(0, 0, 0, 0.08),
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ const twConfig = {
|
|||
},
|
||||
|
||||
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>)",
|
||||
|
||||
accent: "hsl(var(--fo-a) / <alpha-value>)",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
--background: 0 0% 100%;
|
||||
--color-background: 255 255 255;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--fo-border: 20 5.9% 90%;
|
||||
--border: var(--fo-border);
|
||||
--radius: 0.5rem;
|
||||
|
||||
--fo-selection-active: theme(colors.accent/90);
|
||||
|
|
@ -39,7 +40,8 @@
|
|||
|
||||
--background: 0 0% 7.1%;
|
||||
--color-background: 18 18 18;
|
||||
--border: 0 0% 22.1%;
|
||||
--fo-border: 0 0% 22.1%;
|
||||
--border: var(--fo-border);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
--background: 0 0% 100%;
|
||||
--color-background: 255 255 255;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--fo-border: 20 5.9% 90%;
|
||||
--border: var(--fo-border);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +19,8 @@
|
|||
--background: 0 0% 7.1%;
|
||||
--color-background: 18 18 18;
|
||||
|
||||
--border: 0 0% 22.1%;
|
||||
--fo-border: 0 0% 22.1%;
|
||||
--border: var(--fo-border);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1398,8 +1398,8 @@ importers:
|
|||
specifier: 4.1.2
|
||||
version: 4.1.2(patch_hash=dc51df1dccb62feafc2369115ba8e02dba9b261d9cf397f4db6c57d6b4acefe5)(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)
|
||||
react-native-uikit-colors:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3)))
|
||||
specifier: 0.6.2
|
||||
version: 0.6.2(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3)))
|
||||
react-native-volume-manager:
|
||||
specifier: 2.0.8
|
||||
version: 2.0.8(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)
|
||||
|
|
@ -10314,6 +10314,9 @@ packages:
|
|||
os: [darwin]
|
||||
hasBin: true
|
||||
|
||||
apple-uikit-colors@0.6.2:
|
||||
resolution: {integrity: sha512-sv0b92krbTZGNt4AJQLAPioUvuEI/wW2cLqGuEBWl3KpU2JK/odOftx8+dGSC5jGWhdB5qnwBqF96X2FA8FUQA==}
|
||||
|
||||
apple-uikit-colors@1.0.0:
|
||||
resolution: {integrity: sha512-G2Ti2ogMOOC1phfHacSrLacDiE0RcLI7IG3aNoaw7Ack23WI8/9QczmUuhxxkU9BoIR2euCZ2G4lD5qJ+OknwA==}
|
||||
|
||||
|
|
@ -16811,6 +16814,14 @@ packages:
|
|||
shaka-player:
|
||||
optional: true
|
||||
|
||||
react-native-uikit-colors@0.6.2:
|
||||
resolution: {integrity: sha512-qcsQOb/0+3eoYPS/pJG8R5Idxnk+o/UKPwTfa4wCxJ/gFJEcShm1q0wsw9D7BgceXo8tK+aMDebeguvNJYt6Og==}
|
||||
peerDependencies:
|
||||
nativewind: '>=4.1.0'
|
||||
react: 19.2.7
|
||||
react-native: '>=0.76.0'
|
||||
tailwindcss: '>=3.0.0'
|
||||
|
||||
react-native-uikit-colors@1.0.0:
|
||||
resolution: {integrity: sha512-DEEc/OTrNNkIgfgbjSsG4pL0pvBaqtwmjN4FCITHQYeJFupxPOcGJehatMfPE6x3QItyIe45C0sYZz/ish+x6w==}
|
||||
peerDependencies:
|
||||
|
|
@ -17902,6 +17913,9 @@ packages:
|
|||
peerDependencies:
|
||||
tailwindcss: ^4.0.0
|
||||
|
||||
tailwindcss-uikit-colors@0.6.2:
|
||||
resolution: {integrity: sha512-RfF3VVB2nHvJwPZ2ZSTMgKYbZjRny/KW3rP6ufg6/bopxINDQ9TpN/fJXxvKnD86hIU0F2oLIfAv2r6o/ZWcZw==}
|
||||
|
||||
tailwindcss-uikit-colors@1.0.0:
|
||||
resolution: {integrity: sha512-18MGdMVSoXFKjcVkUIu7Q5USqdenAvgFJT9DEocPmdS94b8d/z3s0Uw8LvaNDINfDN9ls24hfg6/hyFqpcZpxw==}
|
||||
|
||||
|
|
@ -29534,6 +29548,8 @@ snapshots:
|
|||
repeat-string: 1.6.1
|
||||
optional: true
|
||||
|
||||
apple-uikit-colors@0.6.2: {}
|
||||
|
||||
apple-uikit-colors@1.0.0: {}
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
|
|
@ -37499,6 +37515,15 @@ snapshots:
|
|||
react: 19.2.7
|
||||
react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)
|
||||
|
||||
react-native-uikit-colors@0.6.2(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))):
|
||||
dependencies:
|
||||
apple-uikit-colors: 0.6.2
|
||||
nativewind: 4.2.6(ea162fae5d41964a07e45415df8f37cb)
|
||||
react: 19.2.7
|
||||
react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)
|
||||
tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))
|
||||
tailwindcss-uikit-colors: 0.6.2
|
||||
|
||||
react-native-uikit-colors@1.0.0(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))):
|
||||
dependencies:
|
||||
apple-uikit-colors: 1.0.0
|
||||
|
|
@ -38886,6 +38911,10 @@ snapshots:
|
|||
dependencies:
|
||||
tailwindcss: 4.3.2
|
||||
|
||||
tailwindcss-uikit-colors@0.6.2:
|
||||
dependencies:
|
||||
apple-uikit-colors: 0.6.2
|
||||
|
||||
tailwindcss-uikit-colors@1.0.0:
|
||||
dependencies:
|
||||
apple-uikit-colors: 1.0.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue