diff --git a/functions/_lib/favorites.test.ts b/functions/_lib/favorites.test.ts new file mode 100644 index 00000000..9b147bde --- /dev/null +++ b/functions/_lib/favorites.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { getAuthenticatedUserId } from './favorites'; + +describe('getAuthenticatedUserId', () => { + it('prefers the Access JWT email payload when available', () => { + const request = new Request('https://example.com', { + headers: { + 'cf-access-authenticated-user-email': 'fallback@example.com', + }, + }); + + expect(getAuthenticatedUserId(request, { + cloudflareAccess: { + JWT: { + payload: { + email: 'Shake.Chen@gmail.com', + aud: 'aud', + exp: 0, + iat: 0, + iss: 'https://example.cloudflareaccess.com', + sub: 'sub', + }, + getIdentity: async () => undefined, + }, + }, + } as any)).toBe('shake.chen@gmail.com'); + }); + + it('falls back to the request header when plugin data is unavailable', () => { + const request = new Request('https://example.com', { + headers: { + 'cf-access-authenticated-user-email': 'Fallback@example.com', + }, + }); + + expect(getAuthenticatedUserId(request)).toBe('fallback@example.com'); + }); +}); diff --git a/functions/_lib/favorites.ts b/functions/_lib/favorites.ts index e056e094..637e6623 100644 --- a/functions/_lib/favorites.ts +++ b/functions/_lib/favorites.ts @@ -1,7 +1,9 @@ +import type { PluginData } from '@cloudflare/pages-plugin-cloudflare-access'; import { normalizeFavoriteToolPaths } from '../../shared/favorites'; -export function getAuthenticatedUserId(request: Request) { - const email = request.headers.get('cf-access-authenticated-user-email')?.trim().toLowerCase(); +export function getAuthenticatedUserId(request: Request, data?: PluginData) { + const email = data?.cloudflareAccess.JWT.payload.email?.trim().toLowerCase() + ?? request.headers.get('cf-access-authenticated-user-email')?.trim().toLowerCase(); return email || null; } diff --git a/functions/api/_middleware.ts b/functions/api/_middleware.ts new file mode 100644 index 00000000..e06ee36e --- /dev/null +++ b/functions/api/_middleware.ts @@ -0,0 +1,9 @@ +import cloudflareAccessPlugin from '@cloudflare/pages-plugin-cloudflare-access'; + +const ACCESS_DOMAIN = 'https://it-tools-9zo.cloudflareaccess.com'; +const ACCESS_AUD = '13d5ba4d30a50a328018a03252fc3c874254f3fe49bcd90cce9afc80cedac485'; + +export const onRequest = cloudflareAccessPlugin({ + domain: ACCESS_DOMAIN, + aud: ACCESS_AUD, +}); diff --git a/functions/api/favorites.ts b/functions/api/favorites.ts index b13dd237..20986860 100644 --- a/functions/api/favorites.ts +++ b/functions/api/favorites.ts @@ -1,5 +1,6 @@ import { getAuthenticatedUserId, normalizeStoredFavoriteToolPaths } from '../_lib/favorites'; import type { D1Database } from '../_lib/d1'; +import type { PluginData } from '@cloudflare/pages-plugin-cloudflare-access'; type Env = { DB: D1Database; @@ -42,8 +43,8 @@ async function saveFavoriteToolPaths(env: Env, userId: string, favoriteToolPaths return normalizedFavoriteToolPaths; } -export async function onRequestGet({ request, env }: { request: Request; env: Env }) { - const userId = getAuthenticatedUserId(request); +export async function onRequestGet({ request, env, data }: { request: Request; env: Env; data: PluginData }) { + const userId = getAuthenticatedUserId(request, data); if (!userId) { return json({ error: 'Unauthorized' }, { status: 401 }); @@ -54,8 +55,8 @@ export async function onRequestGet({ request, env }: { request: Request; env: En return json({ favoriteToolPaths }); } -export async function onRequestPut({ request, env }: { request: Request; env: Env }) { - const userId = getAuthenticatedUserId(request); +export async function onRequestPut({ request, env, data }: { request: Request; env: Env; data: PluginData }) { + const userId = getAuthenticatedUserId(request, data); if (!userId) { return json({ error: 'Unauthorized' }, { status: 401 }); diff --git a/functions/api/me.ts b/functions/api/me.ts index ad14128f..22cbdacb 100644 --- a/functions/api/me.ts +++ b/functions/api/me.ts @@ -1,7 +1,8 @@ import { getAuthenticatedUserId } from '../_lib/favorites'; +import type { PluginData } from '@cloudflare/pages-plugin-cloudflare-access'; -export async function onRequestGet({ request }: { request: Request }) { - const userId = getAuthenticatedUserId(request); +export async function onRequestGet({ request, data }: { request: Request; data: PluginData }) { + const userId = getAuthenticatedUserId(request, data); if (!userId) { return Response.json({ authenticated: false }, { status: 401, headers: { 'cache-control': 'no-store' } }); diff --git a/src/tools/tools.store.test.ts b/src/tools/tools.store.test.ts new file mode 100644 index 00000000..6309bf82 --- /dev/null +++ b/src/tools/tools.store.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +const fetchMock = vi.fn(); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +vi.mock('./index', () => ({ + toolsWithCategory: [ + { + path: '/token-generator', + name: 'Token 生成器', + description: 'Generate tokens', + category: 'General', + }, + ], +})); + +vi.stubGlobal('fetch', fetchMock); + +describe('useToolStore favorites persistence', () => { + beforeEach(() => { + setActivePinia(createPinia()); + window.localStorage.clear(); + fetchMock.mockReset(); + }); + + it('persists a favorite to the remote store even if the user clicks before hydration finishes', async () => { + let resolveFavoritesGet!: (response: Response) => void; + + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (init?.method === 'PUT') { + return Promise.resolve(new Response('', { status: 204 })); + } + + if (url.endsWith('/api/favorites')) { + return new Promise((resolve) => { + resolveFavoritesGet = resolve; + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${url}`)); + }); + + const { useToolStore } = await import('./tools.store'); + const store = useToolStore(); + const favoriteTool = { path: '/token-generator' } as any; + + const addFavoritePromise = store.addToolToFavorites({ tool: favoriteTool }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem('favoriteToolsName')).toBeNull(); + + resolveFavoritesGet(new Response(JSON.stringify({ favoriteToolPaths: [] }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + })); + + await addFavoritePromise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith('/api/favorites', expect.objectContaining({ + method: 'PUT', + })); + expect(window.localStorage.getItem('favoriteToolsName')).toBe(JSON.stringify(['/token-generator'])); + }); +}); diff --git a/src/tools/tools.store.ts b/src/tools/tools.store.ts index a767af7f..e1a1b7a3 100644 --- a/src/tools/tools.store.ts +++ b/src/tools/tools.store.ts @@ -60,8 +60,13 @@ async function saveFavoriteToolPaths(paths: string[]) { } function queueFavoritePersistence() { + const currentFavoriteToolPaths = normalizeFavoriteToolPaths(favoriteToolPaths.value); + favoritePersistenceQueue = favoritePersistenceQueue.then(async () => { - const currentFavoriteToolPaths = normalizeFavoriteToolPaths(favoriteToolPaths.value); + if (favoriteSyncMode.value === 'loading') { + await hydrateFavoriteToolPaths(); + favoriteToolPaths.value = currentFavoriteToolPaths; + } if (favoriteSyncMode.value !== 'remote') { writeCachedFavoriteToolPaths(currentFavoriteToolPaths);