diff --git a/scripts/tools/verify-meta-id-match.sh b/scripts/tools/verify-meta-id-match.sh new file mode 100755 index 00000000..80a16a95 --- /dev/null +++ b/scripts/tools/verify-meta-id-match.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly GRAPH_API_BASE_URL="https://graph.facebook.com" +readonly GRAPH_API_VERSION="v25.0" +readonly PSID="27511427698460020" +readonly ASID="122111097783118257" +readonly PAGE_ID="TODO_PAGE_ID" +readonly APP_ID="TODO_APP_ID" +readonly PAGE_ACCESS_TOKEN="TODO_PAGE_ACCESS_TOKEN" +readonly ACCESS_TOKEN="$PAGE_ACCESS_TOKEN" +readonly APP_SECRET="TODO_APP_SECRET" + +usage() { + cat <<'EOF' +Verify Meta PSID / ASID matching in both directions. + +Usage: + bash scripts/tools/verify-meta-id-match.sh + +Built-in IDs: + PSID 27511427698460020 + ASID 122111097783118257 + +Before running: + Fill PAGE_ID, APP_ID, PAGE_ACCESS_TOKEN, and APP_SECRET constants at the top + of this script. + +Exit code: + 0 Both directions matched. + 1 API call succeeded, but one or both directions did not match. + 2 Missing required input. +EOF +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +if [ "$#" -gt 0 ]; then + echo "This script does not accept runtime parameters. Edit constants in the script instead." >&2 + usage >&2 + exit 2 +fi + +require_value() { + local name="$1" + local value="$2" + if [ -n "$value" ]; then + return + fi + echo "Missing required value: $name" >&2 + exit 2 +} + +require_command() { + local name="$1" + if command -v "$name" >/dev/null 2>&1; then + return + fi + echo "Missing required command: $name" >&2 + exit 2 +} + +require_configured() { + local name="$1" + local value="$2" + require_value "$name" "$value" + if [[ "$value" == TODO_* ]]; then + echo "Please fill script constant: $name" >&2 + exit 2 + fi +} + +require_configured "PAGE_ID" "$PAGE_ID" +require_configured "APP_ID" "$APP_ID" +require_configured "PAGE_ACCESS_TOKEN" "$PAGE_ACCESS_TOKEN" +require_configured "ACCESS_TOKEN" "$ACCESS_TOKEN" +require_configured "APP_SECRET" "$APP_SECRET" + +require_command curl +require_command node + +urlencode() { + node -e 'process.stdout.write(encodeURIComponent(process.argv[1] ?? ""))' "$1" +} + +appsecret_proof() { + local token="$1" + if [ -z "$APP_SECRET" ]; then + return + fi + node -e ' + const crypto = require("crypto"); + const token = process.argv[1] ?? ""; + const secret = process.argv[2] ?? ""; + process.stdout.write( + crypto.createHmac("sha256", secret).update(token).digest("hex") + ); + ' "$token" "$APP_SECRET" +} + +json_ids() { + local file="$1" + node -e ' + const fs = require("fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const ids = Array.isArray(payload.data) + ? payload.data.map((item) => item && item.id).filter(Boolean) + : []; + process.stdout.write(ids.join("\n")); + ' "$file" +} + +json_has_id() { + local file="$1" + local expected="$2" + node -e ' + const fs = require("fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const expected = process.argv[2]; + const matched = Array.isArray(payload.data) + && payload.data.some((item) => item && String(item.id) === expected); + process.exit(matched ? 0 : 1); + ' "$file" "$expected" +} + +print_ids() { + local file="$1" + local ids + ids="$(json_ids "$file")" + if [ -z "$ids" ]; then + echo "Returned IDs: " + return + fi + echo "Returned IDs:" + while IFS= read -r id; do + echo " - $id" + done <<<"$ids" +} + +request_graph() { + local output_file="$1" + local endpoint="$2" + shift 2 + + local http_code + if ! http_code="$( + curl -sS -w '%{http_code}' -o "$output_file" -G "$endpoint" "$@" + )"; then + echo "Graph API request failed before receiving a response." >&2 + return 1 + fi + + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "Graph API returned HTTP $http_code:" >&2 + cat "$output_file" >&2 + echo >&2 + return 1 + fi +} + +tmp_psid_to_asid="$(mktemp)" +tmp_asid_to_psid="$(mktemp)" +cleanup() { + rm -f "$tmp_psid_to_asid" "$tmp_asid_to_psid" +} +trap cleanup EXIT + +encoded_psid="$(urlencode "$PSID")" +encoded_asid="$(urlencode "$ASID")" + +psid_to_asid_endpoint="${GRAPH_API_BASE_URL}/${GRAPH_API_VERSION}/${encoded_psid}/ids_for_apps" +asid_to_psid_endpoint="${GRAPH_API_BASE_URL}/${GRAPH_API_VERSION}/${encoded_asid}/ids_for_pages" + +psid_proof="$(appsecret_proof "$PAGE_ACCESS_TOKEN")" +asid_proof="$(appsecret_proof "$ACCESS_TOKEN")" + +echo "=== PSID -> ASID ===" +echo "GET ${psid_to_asid_endpoint}?app=${APP_ID}&access_token=&appsecret_proof=" + +psid_args=( + --data-urlencode "app=${APP_ID}" + --data-urlencode "access_token=${PAGE_ACCESS_TOKEN}" +) +if [ -n "$psid_proof" ]; then + psid_args+=(--data-urlencode "appsecret_proof=${psid_proof}") +fi + +request_graph "$tmp_psid_to_asid" "$psid_to_asid_endpoint" "${psid_args[@]}" +print_ids "$tmp_psid_to_asid" + +psid_to_asid_matched=0 +if json_has_id "$tmp_psid_to_asid" "$ASID"; then + echo "Match: yes, ASID was found from PSID." +else + echo "Match: no, ASID was not found from PSID." + psid_to_asid_matched=1 +fi + +echo +echo "=== ASID -> PSID ===" +echo "GET ${asid_to_psid_endpoint}?page=${PAGE_ID}&access_token=" + +asid_args=( + --data-urlencode "page=${PAGE_ID}" + --data-urlencode "access_token=${ACCESS_TOKEN}" +) +if [ -n "$asid_proof" ]; then + asid_args+=(--data-urlencode "appsecret_proof=${asid_proof}") +fi + +request_graph "$tmp_asid_to_psid" "$asid_to_psid_endpoint" "${asid_args[@]}" +print_ids "$tmp_asid_to_psid" + +asid_to_psid_matched=0 +if json_has_id "$tmp_asid_to_psid" "$PSID"; then + echo "Match: yes, PSID was found from ASID." +else + echo "Match: no, PSID was not found from ASID." + asid_to_psid_matched=1 +fi + +echo +if [ "$psid_to_asid_matched" -eq 0 ] && [ "$asid_to_psid_matched" -eq 0 ]; then + echo "Result: matched in both directions." + exit 0 +fi + +echo "Result: not matched in both directions." +exit 1 diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index efbe5698..31571861 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -13,8 +13,8 @@ const log = new Logger("ExternalEntryPersist"); interface ExternalEntryPersistProps { deviceId: string | null; - facebookId: string | null; - facebookAppId: string | null; + asid: string | null; + psid: string | null; avatarUrl: string | null; target: string | null; redirect: string | null; @@ -23,8 +23,8 @@ interface ExternalEntryPersistProps { export default function ExternalEntryPersist({ deviceId, - facebookId, - facebookAppId, + asid, + psid, avatarUrl, target, redirect, @@ -43,8 +43,8 @@ export default function ExternalEntryPersist({ try { await persistExternalEntryPayload({ deviceId, - facebookId, - facebookAppId, + asid, + psid, avatarUrl, }); } catch (error) { @@ -55,10 +55,10 @@ export default function ExternalEntryPersist({ })(); }, [ avatarUrl, + asid, deviceId, - facebookAppId, - facebookId, next, + psid, redirect, router, target, diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 13b26e07..7fd29251 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -2,7 +2,7 @@ * 外部入口落地页(Server Component) * * 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如: - * `/external-entry?target=chat&fbid=xxx&fb_app_id=yyy` + * `/external-entry?target=chat&asid=xxx&psid=yyy` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 @@ -24,20 +24,8 @@ export default async function ExternalEntryPage({ return ( > { - return SpAsyncUtil.getString(StorageKeys.facebookId); + async getAsid(): Promise> { + return getStringWithLegacyFallback( + StorageKeys.asid, + StorageKeys.legacyFacebookId, + ); } - setFacebookId(id: string): Promise> { - return SpAsyncUtil.setString(StorageKeys.facebookId, id); + setAsid(id: string): Promise> { + return SpAsyncUtil.setString(StorageKeys.asid, id); } - clearFacebookId(): Promise> { - return SpAsyncUtil.remove(StorageKeys.facebookId); + clearAsid(): Promise> { + return removeKeys(StorageKeys.asid, StorageKeys.legacyFacebookId); } - // ---- facebook app id ---- - - getFacebookAppId(): Promise> { - return SpAsyncUtil.getString(StorageKeys.facebookAppId); + async getPsid(): Promise> { + return getStringWithLegacyFallback( + StorageKeys.psid, + StorageKeys.legacyFacebookAppId, + ); } - setFacebookAppId(id: string): Promise> { - return SpAsyncUtil.setString(StorageKeys.facebookAppId, id); + setPsid(id: string): Promise> { + return SpAsyncUtil.setString(StorageKeys.psid, id); } - clearFacebookAppId(): Promise> { - return SpAsyncUtil.remove(StorageKeys.facebookAppId); + clearPsid(): Promise> { + return removeKeys(StorageKeys.psid, StorageKeys.legacyFacebookAppId); } // ---- bulk clear ---- @@ -120,9 +124,9 @@ export class AuthStorage implements IAuthStorage { if (!r1.success) return r1; const r2 = await this.clearRefreshToken(); if (!r2.success) return r2; - const r3 = await this.clearFacebookId(); + const r3 = await this.clearAsid(); if (!r3.success) return r3; - const r4 = await this.clearFacebookAppId(); + const r4 = await this.clearPsid(); if (!r4.success) return r4; return this.clearLoginProvider(); } @@ -136,12 +140,30 @@ export class AuthStorage implements IAuthStorage { if (!r3.success) return r3; const r4 = await this.clearRefreshToken(); if (!r4.success) return r4; - const r5 = await this.clearFacebookId(); + const r5 = await this.clearAsid(); if (!r5.success) return r5; - return this.clearFacebookAppId(); + return this.clearPsid(); } } +async function getStringWithLegacyFallback( + key: string, + legacyKey: string, +): Promise> { + const value = await SpAsyncUtil.getString(key); + if (!value.success) return value; + if (value.data !== null && value.data.length > 0) return value; + return SpAsyncUtil.getString(legacyKey); +} + +async function removeKeys(...keys: string[]): Promise> { + for (const key of keys) { + const result = await SpAsyncUtil.remove(key); + if (!result.success) return result; + } + return Result.ok(undefined); +} + function isLoginStatus(value: string): value is LoginStatusT { return Object.values(LoginStatus).includes(value as LoginStatusT); } diff --git a/src/data/storage/auth/iauth_storage.ts b/src/data/storage/auth/iauth_storage.ts index 3a003687..971bc7dd 100644 --- a/src/data/storage/auth/iauth_storage.ts +++ b/src/data/storage/auth/iauth_storage.ts @@ -4,7 +4,7 @@ * IAuthStorage 接口 * * 对齐 Dart 端 `IAuthStorage`(lib/data/services/storage/iauth_storage.dart): - * - loginToken / loginProvider / guestToken / deviceId / refreshToken / facebookId / facebookAppId 字段的 CRUD + * - loginToken / loginProvider / guestToken / deviceId / refreshToken / ASID / PSID 字段的 CRUD * - `clearAuthData()` 批量清空登录态 * - `clearBusinessAuthData()` 仅清空真实用户登录态,保留 guest token / deviceId * - 所有方法返回 `Promise>`,与 Dart `Future>` 对齐 @@ -35,13 +35,13 @@ export interface IAuthStorage { setRefreshToken(token: string): Promise>; clearRefreshToken(): Promise>; - getFacebookId(): Promise>; - setFacebookId(id: string): Promise>; - clearFacebookId(): Promise>; + getAsid(): Promise>; + setAsid(id: string): Promise>; + clearAsid(): Promise>; - getFacebookAppId(): Promise>; - setFacebookAppId(id: string): Promise>; - clearFacebookAppId(): Promise>; + getPsid(): Promise>; + setPsid(id: string): Promise>; + clearPsid(): Promise>; clearBusinessAuthData(): Promise>; clearAuthData(): Promise>; diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index 693640a0..f38566d7 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -13,8 +13,10 @@ export const StorageKeys = { guestToken: "guest_token", deviceId: "device_id", refreshToken: "refresh_token", - facebookId: "facebook_id", - facebookAppId: "facebook_app_id", + asid: "asid", + psid: "psid", + legacyFacebookId: "facebook_id", + legacyFacebookAppId: "facebook_app_id", // user user: "user", diff --git a/src/lib/auth/__tests__/auth_session.test.ts b/src/lib/auth/__tests__/auth_session.test.ts index fc913f69..71d9bcfe 100644 --- a/src/lib/auth/__tests__/auth_session.test.ts +++ b/src/lib/auth/__tests__/auth_session.test.ts @@ -32,12 +32,12 @@ function makeAuthStorageMock(input: { ), setRefreshToken: vi.fn(async () => Result.ok(undefined)), clearRefreshToken: vi.fn(async () => Result.ok(undefined)), - getFacebookId: vi.fn(async () => Result.ok(null)), - setFacebookId: vi.fn(async () => Result.ok(undefined)), - clearFacebookId: vi.fn(async () => Result.ok(undefined)), - getFacebookAppId: vi.fn(async () => Result.ok(null)), - setFacebookAppId: vi.fn(async () => Result.ok(undefined)), - clearFacebookAppId: vi.fn(async () => Result.ok(undefined)), + getAsid: vi.fn(async () => Result.ok(null)), + setAsid: vi.fn(async () => Result.ok(undefined)), + clearAsid: vi.fn(async () => Result.ok(undefined)), + getPsid: vi.fn(async () => Result.ok(null)), + setPsid: vi.fn(async () => Result.ok(undefined)), + clearPsid: vi.fn(async () => Result.ok(undefined)), clearBusinessAuthData: vi.fn(async () => Result.ok(undefined)), clearAuthData: vi.fn(async () => Result.ok(undefined)), }; diff --git a/src/lib/chat/chat_external_browser.ts b/src/lib/chat/chat_external_browser.ts index e5c3b0fd..f553b101 100644 --- a/src/lib/chat/chat_external_browser.ts +++ b/src/lib/chat/chat_external_browser.ts @@ -8,26 +8,26 @@ import { UrlLauncherUtil } from "@/utils"; export async function openChatInExternalBrowser(): Promise { const authStorage = AuthStorage.getInstance(); const userStorage = UserStorage.getInstance(); - const [deviceIdR, facebookIdR, facebookAppIdR, avatarUrlR] = + const [deviceIdR, asidR, psidR, avatarUrlR] = await Promise.all([ authStorage.getDeviceId(), - authStorage.getFacebookId(), - authStorage.getFacebookAppId(), + authStorage.getAsid(), + authStorage.getPsid(), userStorage.getAvatarUrl(), ]); const deviceId = deviceIdR.success ? deviceIdR.data : null; - const facebookId = facebookIdR.success ? facebookIdR.data : null; - const facebookAppId = facebookAppIdR.success ? facebookAppIdR.data : null; + const asid = asidR.success ? asidR.data : null; + const psid = psidR.success ? psidR.data : null; const avatarUrl = avatarUrlR.success ? avatarUrlR.data : null; - if (deviceId && facebookId) { + if (deviceId && asid) { UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.externalEntry, { queryParams: { target: "chat", deviceId, - fbid: facebookId, - ...(facebookAppId ? { fb_app_id: facebookAppId } : {}), + asid, + ...(psid ? { psid } : {}), ...(avatarUrl ? { avatarUrl } : {}), }, }); diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index 8a12572d..bb2bf6da 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -11,8 +11,8 @@ export type ExternalEntryTarget = export interface ExternalEntryPayload { deviceId?: string | null; - facebookId?: string | null; - facebookAppId?: string | null; + asid?: string | null; + psid?: string | null; avatarUrl?: string | null; } @@ -37,8 +37,8 @@ export function resolveExternalEntryTarget({ export async function persistExternalEntryPayload({ deviceId, - facebookId, - facebookAppId, + asid, + psid, avatarUrl, }: ExternalEntryPayload): Promise { const authStorage = AuthStorage.getInstance(); @@ -48,11 +48,11 @@ export async function persistExternalEntryPayload({ if (hasValue(deviceId)) { tasks.push(authStorage.setDeviceId(deviceId)); } - if (hasValue(facebookId)) { - tasks.push(authStorage.setFacebookId(facebookId)); + if (hasValue(asid)) { + tasks.push(authStorage.setAsid(asid)); } - if (hasValue(facebookAppId)) { - tasks.push(authStorage.setFacebookAppId(facebookAppId)); + if (hasValue(psid)) { + tasks.push(authStorage.setPsid(psid)); } if (hasValue(avatarUrl)) { tasks.push(userStorage.setAvatarUrl(avatarUrl)); diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 1cfcf0e5..0483b78a 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -148,7 +148,7 @@ export const syncFacebookBackendActor = fromPromise< /** * 用 accessToken 调 Facebook Graph `/me` 拉用户资料,写本地: - * - `id` → `AuthStorage.setFacebookId()`(独立于后端 userId) + * - `id` → `AuthStorage.setAsid()`(独立于后端 userId) * - `pictureUrl` → `UserStorage.setAvatarUrl()`(头像专用 slot,便于快速读取) * * 全部 best-effort:单条失败只 warn,不抛错。`pictureUrl == null` 也算成功 @@ -174,11 +174,11 @@ async function persistFacebookProfile(accessToken: string): Promise { const authStorage = AuthStorage.getInstance(); if (fbUser.id) { - const r = await authStorage.setFacebookId(fbUser.id); + const r = await authStorage.setAsid(fbUser.id); if (!r.success) { log.warn( { err: r.error.message }, - "[auth-machine] syncFacebookBackendActor: setFacebookId failed", + "[auth-machine] syncFacebookBackendActor: setAsid failed", ); } } @@ -247,13 +247,13 @@ export const checkAuthStatusActor = fromPromise(async () => { return "email" as LoginStatus; } - // 3. 外部浏览器深链同步:仅 fbid 落地时,用它换业务 token。 - const facebookIdR = await storage.getFacebookId(); - if (facebookIdR.success && facebookIdR.data) { + // 3. 外部浏览器深链同步:仅 asid 落地时,用它换业务 token。 + const asidR = await storage.getAsid(); + if (asidR.success && asidR.data) { const authRepo = getAuthRepository(); const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); const result = await authRepo.facebookIdLogin({ - fbId: facebookIdR.data, + fbId: asidR.data, avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined, });