diff --git a/apps/mobile/src/lib/error-message.test.ts b/apps/mobile/src/lib/error-message.test.ts new file mode 100644 index 000000000..4068f2af0 --- /dev/null +++ b/apps/mobile/src/lib/error-message.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" + +import { sanitizeErrorMessage } from "./error-message" + +describe("sanitizeErrorMessage", () => { + it("removes Follow API request context from display messages", () => { + const message = [ + "RSSHub feed subscription limit exceeded", + "Request: POST /subscriptions (original: /subscriptions)", + "Args: {", + ' "headers": {', + ' "cookie": "session=secret"', + " }", + "}", + ].join("\n") + + expect(sanitizeErrorMessage(message)).toBe("RSSHub feed subscription limit exceeded") + }) + + it("supports CRLF request context", () => { + expect( + sanitizeErrorMessage("Subscription limit exceeded\r\nRequest: POST /subscriptions"), + ).toBe("Subscription limit exceeded") + }) + + it("preserves ordinary error messages", () => { + expect(sanitizeErrorMessage("Unable to follow this feed")).toBe("Unable to follow this feed") + }) +}) diff --git a/apps/mobile/src/lib/error-message.ts b/apps/mobile/src/lib/error-message.ts new file mode 100644 index 000000000..7e8544a5e --- /dev/null +++ b/apps/mobile/src/lib/error-message.ts @@ -0,0 +1,4 @@ +const FOLLOW_API_REQUEST_CONTEXT_PATTERN = /\r?\nRequest:[\s\S]*$/u + +export const sanitizeErrorMessage = (message: string) => + message.replace(FOLLOW_API_REQUEST_CONTEXT_PATTERN, "").trim() diff --git a/apps/mobile/src/lib/error-parser.test.ts b/apps/mobile/src/lib/error-parser.test.ts new file mode 100644 index 000000000..8d0796388 --- /dev/null +++ b/apps/mobile/src/lib/error-parser.test.ts @@ -0,0 +1,79 @@ +import { FollowAPIError } from "@follow-app/client-sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { toastFetchError } from "./error-parser" + +const mocks = vi.hoisted(() => ({ + isPaymentEnabled: false, + showUpgradeRequiredDialog: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock("i18next", () => ({ + t: (key: string) => { + if (key === "errors:2012") { + return "RSSHub feed subscription limit exceeded" + } + return key.replace(/^errors:/u, "") + }, +})) + +vi.mock("@/src/atoms/server-configs", () => ({ + getIsPaymentEnabled: () => mocks.isPaymentEnabled, +})) + +vi.mock("@/src/modules/dialogs/UpgradeRequiredDialog", () => ({ + showUpgradeRequiredDialog: mocks.showUpgradeRequiredDialog, +})) + +vi.mock("./toast", () => ({ + toast: { + error: mocks.toastError, + }, +})) + +describe("toastFetchError", () => { + beforeEach(() => { + mocks.isPaymentEnabled = false + mocks.showUpgradeRequiredDialog.mockClear() + mocks.toastError.mockClear() + }) + + it("shows a concise upgrade dialog for RSSHub subscription limits", () => { + mocks.isPaymentEnabled = true + const error = new FollowAPIError( + [ + "RSSHub feed subscription limit exceeded", + "Request: POST /subscriptions (original: /subscriptions)", + 'Args: { "headers": { "cookie": "session=secret" } }', + ].join("\n"), + 402, + "2012", + ) + + toastFetchError(error) + + expect(mocks.showUpgradeRequiredDialog).toHaveBeenCalledWith({ + title: "RSSHub feed subscription limit exceeded", + message: "settings:subscription.summary.free_description", + }) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it("does not expose request context when an API error code has no translation", () => { + const error = new FollowAPIError( + [ + "Unable to follow this feed", + "Request: POST /subscriptions (original: /subscriptions)", + 'Args: { "headers": { "cookie": "session=secret" } }', + ].join("\n"), + 400, + "29999", + ) + + toastFetchError(error) + + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(mocks.toastError).toHaveBeenCalledWith("Unable to follow this feed") + }) +}) diff --git a/apps/mobile/src/lib/error-parser.ts b/apps/mobile/src/lib/error-parser.ts index 76ec4968f..cfcea8b4e 100644 --- a/apps/mobile/src/lib/error-parser.ts +++ b/apps/mobile/src/lib/error-parser.ts @@ -5,6 +5,7 @@ import { FetchError } from "ofetch" import { getIsPaymentEnabled } from "@/src/atoms/server-configs" import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog" +import { sanitizeErrorMessage } from "./error-message" import { toast } from "./toast" export const getFetchErrorInfo = ( @@ -21,11 +22,11 @@ export const getFetchErrorInfo = ( const i18nKey = `errors:${code}` as any const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey) return { - message: `${i18nMessage}${reason ? `: ${reason}` : ""}`, + message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`), code, } } catch { - return { message: error.message } + return { message: sanitizeErrorMessage(error.message) } } } @@ -35,15 +36,15 @@ export const getFetchErrorInfo = ( const i18nKey = `errors:${code}` as any const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey) return { - message: i18nMessage, + message: sanitizeErrorMessage(i18nMessage), code, } } catch { - return { message: error.message } + return { message: sanitizeErrorMessage(error.message) } } } - return { message: error.message } + return { message: sanitizeErrorMessage(error.message) } } export const getFetchErrorMessage = (error: Error) => { @@ -58,7 +59,7 @@ export const createErrorToaster = (title?: string) => (err: Error) => toastFetchError(err, { title }) export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => { - const { message: fallbackMessage } = error + const fallbackMessage = sanitizeErrorMessage(error.message) let message = fallbackMessage let _reason = "" let code: number | undefined @@ -113,6 +114,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin } } + message = sanitizeErrorMessage(message) + // 2fa errors are handled by the form if (code === 4007 || code === 4008) { return diff --git a/locales/errors/en.json b/locales/errors/en.json index f64b47248..e90203b29 100644 --- a/locales/errors/en.json +++ b/locales/errors/en.json @@ -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", diff --git a/locales/errors/fr-FR.json b/locales/errors/fr-FR.json index d102194a9..c671273f3 100644 --- a/locales/errors/fr-FR.json +++ b/locales/errors/fr-FR.json @@ -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", diff --git a/locales/errors/ja.json b/locales/errors/ja.json index 3f6b37662..5d38e35fc 100644 --- a/locales/errors/ja.json +++ b/locales/errors/ja.json @@ -19,6 +19,7 @@ "2004": "フィードの解析に失敗しました", "2010": "所有権の確認に失敗しました", "2011": "購読制限を超えました", + "2012": "RSSHubフィードの購読数制限を超えました", "3000": "エントリが見つかりません", "4000": "すでに請求されています", "4001": "ユーザーウォレットエラー", diff --git a/locales/errors/zh-CN.json b/locales/errors/zh-CN.json index 3678eaa85..747396bbd 100644 --- a/locales/errors/zh-CN.json +++ b/locales/errors/zh-CN.json @@ -19,6 +19,7 @@ "2004": "订阅源解析失败", "2010": "所有权挑战失败", "2011": "超出订阅限制", + "2012": "RSSHub 订阅源数量限制已超出", "3000": "未找到条目", "4000": "已被认领", "4001": "用户钱包错误", diff --git a/locales/errors/zh-TW.json b/locales/errors/zh-TW.json index 7437c8387..91f5c816d 100644 --- a/locales/errors/zh-TW.json +++ b/locales/errors/zh-TW.json @@ -19,6 +19,7 @@ "2004": "訂閱源解析失敗", "2010": "所有權挑戰失敗", "2011": "超過訂閱限制", + "2012": "RSSHub 訂閱源數量限制已超過", "3000": "條目未找到", "4000": "已被認領", "4001": "用戶錢包錯誤",