fix: use access plugin for favorites sync (#3)

This commit is contained in:
shake 2026-06-22 19:05:24 +08:00
parent b530304a15
commit 086ad26069
7 changed files with 140 additions and 9 deletions

View file

@ -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');
});
});

View file

@ -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;
}

View file

@ -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,
});

View file

@ -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 });

View file

@ -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' } });

View file

@ -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<Response>((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']));
});
});

View file

@ -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);