api: use node api endpoint for iap verifying (#1683)

This commit is contained in:
Huang Xin
2025-07-26 19:34:36 +08:00
committed by GitHub
parent 8d5c98004b
commit 686f8988bc
30 changed files with 428 additions and 189 deletions
+4 -4
View File
@@ -33,7 +33,7 @@
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview --ip 0.0.0.0",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
@@ -46,7 +46,7 @@
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.3.1",
"@opennextjs/cloudflare": "^1.6.1",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
@@ -106,7 +106,7 @@
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
"@types/node": "^22.15.31",
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
@@ -132,6 +132,6 @@
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"wrangler": "^4.21.0"
"wrangler": "^4.26.0"
}
}
@@ -474,5 +474,6 @@
"Failed to restore purchases.": "فشل في استعادة المشتريات.",
"Failed to manage subscription.": "فشل في إدارة الاشتراك.",
"Failed to load subscription plans.": "فشل في تحميل خطط الاشتراك.",
"year": "سنة"
"year": "سنة",
"Failed to create checkout session": "فشل في إنشاء جلسة الدفع"
}
@@ -458,5 +458,6 @@
"Failed to restore purchases.": "Wiederherstellung der Käufe fehlgeschlagen.",
"Failed to manage subscription.": "Verwaltung des Abonnements fehlgeschlagen.",
"Failed to load subscription plans.": "Laden der Abonnementpläne fehlgeschlagen.",
"year": "Jahr"
"year": "Jahr",
"Failed to create checkout session": "Fehler beim Erstellen der Checkout-Sitzung"
}
@@ -458,5 +458,6 @@
"Failed to restore purchases.": "Αποτυχία επαναφοράς αγορών.",
"Failed to manage subscription.": "Αποτυχία διαχείρισης συνδρομής.",
"Failed to load subscription plans.": "Αποτυχία φόρτωσης σχεδίων συνδρομής.",
"year": "έτος"
"year": "έτος",
"Failed to create checkout session": "Αποτυχία δημιουργίας συνεδρίας πληρωμής"
}
@@ -462,5 +462,6 @@
"Failed to restore purchases.": "Error al restaurar compras.",
"Failed to manage subscription.": "Error al gestionar la suscripción.",
"Failed to load subscription plans.": "Error al cargar los planes de suscripción.",
"year": "año"
"year": "año",
"Failed to create checkout session": "Error al crear la sesión de pago"
}
@@ -462,5 +462,6 @@
"Failed to restore purchases.": "Échec de la restauration des achats.",
"Failed to manage subscription.": "Échec de la gestion de l'abonnement.",
"Failed to load subscription plans.": "Échec du chargement des plans d'abonnement.",
"year": "année"
"year": "année",
"Failed to create checkout session": "Échec de la création de la session de paiement"
}
@@ -458,5 +458,6 @@
"Failed to restore purchases.": "खरीदारी पुनर्स्थापित करने में विफल।",
"Failed to manage subscription.": "सदस्यता प्रबंधित करने में विफल।",
"Failed to load subscription plans.": "सदस्यता योजनाएँ लोड करने में विफल।",
"year": "वर्ष"
"year": "वर्ष",
"Failed to create checkout session": "चेकआउट सत्र बनाने में विफल"
}
@@ -454,5 +454,6 @@
"Failed to restore purchases.": "Gagal memulihkan pembelian.",
"Failed to manage subscription.": "Gagal mengelola langganan.",
"Failed to load subscription plans.": "Gagal memuat rencana langganan.",
"year": "tahun"
"year": "tahun",
"Failed to create checkout session": "Failed to create checkout session"
}
@@ -462,5 +462,6 @@
"Failed to restore purchases.": "Impossibile ripristinare gli acquisti.",
"Failed to manage subscription.": "Impossibile gestire l'abbonamento.",
"Failed to load subscription plans.": "Impossibile caricare i piani di abbonamento.",
"year": "anno"
"year": "anno",
"Failed to create checkout session": "Impossibile creare la sessione di checkout"
}
@@ -454,5 +454,6 @@
"Failed to restore purchases.": "購入の復元に失敗しました。",
"Failed to manage subscription.": "サブスクリプションの管理に失敗しました。",
"Failed to load subscription plans.": "サブスクリプションプランの読み込みに失敗しました。",
"year": "年"
"year": "年",
"Failed to create checkout session": "チェックアウトセッションの作成に失敗しました"
}
@@ -454,5 +454,6 @@
"Failed to restore purchases.": "구매 복원에 실패했습니다.",
"Failed to manage subscription.": "구독 관리에 실패했습니다.",
"Failed to load subscription plans.": "구독 플랜 로드에 실패했습니다.",
"year": "년"
"year": "년",
"Failed to create checkout session": "체크아웃 세션 생성 실패"
}
@@ -458,5 +458,6 @@
"Failed to restore purchases.": "Herstellen van aankopen is mislukt.",
"Failed to manage subscription.": "Beheren van abonnement is mislukt.",
"Failed to load subscription plans.": "Laden van abonnement plannen is mislukt.",
"year": "jaar"
"year": "jaar",
"Failed to create checkout session": "Kon de afreken sessie niet aanmaken"
}
@@ -466,5 +466,6 @@
"Failed to restore purchases.": "Nie udało się przywrócić zakupów.",
"Failed to manage subscription.": "Nie udało się zarządzać subskrypcją.",
"Failed to load subscription plans.": "Nie udało się załadować planów subskrypcyjnych.",
"year": "rok"
"year": "rok",
"Failed to create checkout session": "Nie udało się utworzyć sesji zakupu"
}
@@ -462,5 +462,6 @@
"Failed to restore purchases.": "Falha ao restaurar compras.",
"Failed to manage subscription.": "Falha ao gerenciar assinatura.",
"Failed to load subscription plans.": "Falha ao carregar planos de assinatura.",
"year": "ano"
"year": "ano",
"Failed to create checkout session": "Falha ao criar sessão de checkout"
}
@@ -466,5 +466,6 @@
"Failed to restore purchases.": "Не удалось восстановить покупки.",
"Failed to manage subscription.": "Не удалось управлять подпиской.",
"Failed to load subscription plans.": "Не удалось загрузить планы подписки.",
"year": "год"
"year": "год",
"Failed to create checkout session": "Не удалось создать сессию оформления заказа"
}
@@ -454,5 +454,6 @@
"Failed to restore purchases.": "ไม่สามารถกู้คืนการซื้อได้",
"Failed to manage subscription.": "ไม่สามารถจัดการการสมัครสมาชิกได้",
"Failed to load subscription plans.": "ไม่สามารถโหลดแผนการสมัครสมาชิกได้",
"year": "ปี"
"year": "ปี",
"Failed to create checkout session": "ไม่สามารถสร้างเซสชันการชำระเงินได้"
}
@@ -458,5 +458,6 @@
"Failed to restore purchases.": "Satın alımları geri yükleme başarısız oldu.",
"Failed to manage subscription.": "Aboneliği yönetme başarısız oldu.",
"Failed to load subscription plans.": "Abonelik planlarını yükleme başarısız oldu.",
"year": "yıl"
"year": "yıl",
"Failed to create checkout session": "Ödeme oturumu oluşturma başarısız oldu"
}
@@ -466,5 +466,6 @@
"Failed to restore purchases.": "Не вдалося відновити покупки.",
"Failed to manage subscription.": "Не вдалося керувати підпискою.",
"Failed to load subscription plans.": "Не вдалося завантажити плани підписки.",
"year": "рік"
"year": "рік",
"Failed to create checkout session": "Не вдалося створити сесію оформлення замовлення"
}
@@ -454,5 +454,6 @@
"Failed to restore purchases.": "Khôi phục giao dịch mua không thành công.",
"Failed to manage subscription.": "Quản lý đăng ký không thành công.",
"Failed to load subscription plans.": "Tải kế hoạch đăng ký không thành công.",
"year": "năm"
"year": "năm",
"Failed to create checkout session": "Không thể tạo phiên làm việc thanh toán"
}
@@ -454,5 +454,6 @@
"Failed to restore purchases.": "恢复购买失败。",
"Failed to manage subscription.": "管理订阅失败。",
"Failed to load subscription plans.": "加载订阅计划失败。",
"year": "年"
"year": "年",
"Failed to create checkout session": "创建结账会话失败"
}
@@ -454,5 +454,6 @@
"Failed to restore purchases.": "恢復購買失敗。",
"Failed to manage subscription.": "管理訂閱失敗。",
"Failed to load subscription plans.": "加載訂閱計劃失敗。",
"year": "年"
"year": "年",
"Failed to create checkout session": "創建結帳會話失敗"
}
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { AppleIAPVerifier } from '@/libs/iap/apple/verifier';
import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/iap/apple/verifier';
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
const REAL_TEST_DATA = {
@@ -26,7 +26,7 @@ describe.skipIf(SKIP_IAP_API_TESTS)('Apple IAP Integration Tests', () => {
let verifier: AppleIAPVerifier;
beforeAll(() => {
verifier = new AppleIAPVerifier({
verifier = createAppleIAPVerifier({
keyId: process.env['APPLE_IAP_KEY_ID']!,
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { NextResponse } from 'next/server';
import { defaultIAPVerifier } from '@/libs/iap/apple/verifier';
import { getAppleIAPVerifier } from '@/libs/iap/apple/verifier';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { IAPError } from '@/types/error';
@@ -169,6 +169,7 @@ export async function POST(request: Request) {
});
}
const defaultIAPVerifier = getAppleIAPVerifier();
const verificationResult = await defaultIAPVerifier.verifyTransaction(originalTransactionId);
if (!verificationResult.success) {
console.error('Apple verification failed:', verificationResult.error);
+21 -3
View File
@@ -112,15 +112,27 @@ const ProfilePage = () => {
}
setLoading(true);
const isEmbeddedCheckout = isTauriAppPlatform();
const { sessionId, clientSecret, url } = await fetch(WEB_STRIPE_CHECKOUT_URL, {
const response = await fetch(WEB_STRIPE_CHECKOUT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ priceId, embedded: isEmbeddedCheckout }),
}).then((res) => res.json());
});
setLoading(false);
if (!response.ok) {
console.error('Failed to create Stripe checkout session');
posthog.capture('checkout_error', {
error: 'Failed to create Stripe checkout session',
});
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to create checkout session'),
});
return;
}
const { sessionId, clientSecret, url } = await response.json();
const selectedPlan = availablePlans.find((plan) => plan.price_id === priceId)!;
const planName = selectedPlan.product?.name || selectedPlan.productName;
@@ -193,6 +205,9 @@ const ProfilePage = () => {
try {
const purchases = await iapService.restorePurchases();
if (purchases.length > 0) {
purchases.sort(
(a, b) => new Date(a.purchaseDate).getTime() - new Date(b.purchaseDate).getTime(),
);
const purchase = purchases[0]!;
const params = new URLSearchParams({
payment: 'iap',
@@ -285,7 +300,10 @@ const ProfilePage = () => {
} else {
fetch(WEB_STRIPE_PLANS_URL)
.then((res) => res.json())
.then((data) => setAvailablePlans(data || []));
.then((data) => {
const availablePlans = data && data instanceof Array ? data : [];
setAvailablePlans(availablePlans);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
@@ -2,13 +2,13 @@
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useTranslation } from '@/hooks/useTranslation';
import { getAPIBaseUrl } from '@/services/environment';
import { getAPIBaseUrl, getNodeAPIBaseUrl } from '@/services/environment';
import { getAccessToken } from '@/utils/access';
import { supabase } from '@/utils/supabase';
import Spinner from '@/components/Spinner';
const WEB_STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
const WEB_APPLE_IAP_VERIFY_URL = `${getAPIBaseUrl()}/apple/iap-verify`;
const STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
const APPLE_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/apple/iap-verify`;
interface SessionStatus {
status: 'loading' | 'complete' | 'failed' | 'processing';
@@ -40,7 +40,7 @@ const SuccessPageWithSearchParams = () => {
const updateStripeSessionStatus = async () => {
try {
const token = await getAccessToken();
const response = await fetch(WEB_STRIPE_CHECK_URL, {
const response = await fetch(STRIPE_CHECK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -93,7 +93,7 @@ const SuccessPageWithSearchParams = () => {
if (platform === 'ios') {
try {
const token = await getAccessToken();
const response = await fetch(WEB_APPLE_IAP_VERIFY_URL, {
const response = await fetch(APPLE_IAP_VERIFY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+16 -10
View File
@@ -118,13 +118,19 @@ export class AppleIAPVerifier {
export const createAppleIAPVerifier = (config: AppleIAPConfig) => new AppleIAPVerifier(config);
export const defaultIAPVerifier = new AppleIAPVerifier({
keyId: process.env['APPLE_IAP_KEY_ID']!,
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
privateKey: Buffer.from(process.env['APPLE_IAP_PRIVATE_KEY_BASE64']! || '', 'base64').toString(
'utf-8',
),
environment: 'sandbox',
// environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
let defaultIAPVerifier: AppleIAPVerifier | undefined;
export const getAppleIAPVerifier = () => {
if (!defaultIAPVerifier) {
defaultIAPVerifier = createAppleIAPVerifier({
keyId: process.env['APPLE_IAP_KEY_ID']!,
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
privateKey: Buffer.from(
process.env['APPLE_IAP_PRIVATE_KEY_BASE64']! || '',
'base64',
).toString('utf-8'),
environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
}
return defaultIAPVerifier;
};
@@ -535,6 +535,7 @@ export const BOOK_IDS_SEPARATOR = '+';
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
export const READEST_NODE_BASE_URL = 'https://node.readest.com';
const LATEST_DOWNLOAD_BASE_URL = 'https://download.readest.com/releases';
+9 -3
View File
@@ -1,5 +1,5 @@
import { AppService } from '@/types/system';
import { READEST_WEB_BASE_URL } from './constants';
import { READEST_NODE_BASE_URL, READEST_WEB_BASE_URL } from './constants';
declare global {
interface Window {
@@ -12,12 +12,18 @@ export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] ==
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
export const isPWA = () => window.matchMedia('(display-mode: standalone)').matches;
export const getBaseUrl = () => process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READEST_WEB_BASE_URL;
export const getNodeBaseUrl = () =>
process.env['NEXT_PUBLIC_NODE_BASE_URL'] ?? READEST_NODE_BASE_URL;
const isWebDevMode = () => process.env['NODE_ENV'] === 'development' && isWebAppPlatform();
// Dev API only in development mode and web platform
// with command `pnpm dev-web`
// for production build or tauri app use the production Web API
export const getAPIBaseUrl = () =>
process.env['NODE_ENV'] === 'development' && isWebAppPlatform() ? '/api' : `${getBaseUrl()}/api`;
export const getAPIBaseUrl = () => (isWebDevMode() ? '/api' : `${getBaseUrl()}/api`);
// For Node.js API that currently not supported in some edge runtimes
export const getNodeAPIBaseUrl = () => (isWebDevMode() ? '/api' : `${getNodeBaseUrl()}/api`);
export interface EnvConfigType {
getAppService: () => Promise<AppService>;
+1 -1
View File
@@ -29,7 +29,7 @@
],
"@pdfjs/*": [
"./public/vendor/pdfjs/*"
]
],
}
},
"include": [
+327 -140
View File
File diff suppressed because it is too large Load Diff