diff --git a/apps/web/app/adapters/cloudbase.ts b/apps/web/app/adapters/cloudbase.ts new file mode 100644 index 0000000..e0a5f8e --- /dev/null +++ b/apps/web/app/adapters/cloudbase.ts @@ -0,0 +1,71 @@ +/** Calls a cloud function and resolves its `result`. */ +export type CloudbaseFunctionCaller = (name: string, data: Record) => Promise + +interface CloudbaseApp { + auth: (options?: { persistence: 'session' }) => unknown + callFunction: (options: { name: string, data: Record }) => Promise<{ + errCode?: number | string + errMsg?: string + result?: unknown + } | undefined> +} + +interface CloudbaseModule { + default?: { + init: (config: { + auth: { detectSessionInUrl: false } + env: string + region: 'ap-shanghai' + }) => CloudbaseApp + } + init?: (config: { + auth: { detectSessionInUrl: false } + env: string + region: 'ap-shanghai' + }) => CloudbaseApp +} + +let cachedCloudbaseEnv = '' +let cachedCloudbaseApp: Promise | undefined + +function getCloudbaseApp(env: string): Promise { + if (cachedCloudbaseApp) { + if (cachedCloudbaseEnv !== env) + return Promise.reject(new Error('CloudBase environment cannot change at runtime')) + return cachedCloudbaseApp + } + + cachedCloudbaseEnv = env + cachedCloudbaseApp = (async () => { + const cloudbaseModule = await import('@cloudbase/js-sdk') as unknown as CloudbaseModule + const cloudbase = cloudbaseModule.default ?? cloudbaseModule + if (!cloudbase.init) + throw new Error('@cloudbase/js-sdk does not expose init()') + + return cloudbase.init({ + auth: { detectSessionInUrl: false }, + env, + region: 'ap-shanghai', + }) + })() + cachedCloudbaseApp.catch(() => { + cachedCloudbaseApp = undefined + }) + return cachedCloudbaseApp +} + +/** Call a cloud function on the configured CloudBase environment. */ +export async function callCloudbaseFunction( + env: string, + name: string, + data: Record, +): Promise { + const app = await getCloudbaseApp(env) + // The auth client is created first so the call carries the app's session. + app.auth({ persistence: 'session' }) + const response = await app.callFunction({ name, data }) + // A missing result means the call itself failed (not deployed, crashed, …). + if (!response || response.result === undefined) + throw new Error(`CloudBase function ${name} failed: ${response?.errCode ?? response?.errMsg ?? 'no result'}`) + return response.result +} diff --git a/apps/web/app/adapters/feedback.ts b/apps/web/app/adapters/feedback.ts index bcf33e9..bbfc50d 100644 --- a/apps/web/app/adapters/feedback.ts +++ b/apps/web/app/adapters/feedback.ts @@ -1,4 +1,4 @@ -import type { CloudbaseFunctionCaller } from './yunlefunAuth' +import type { CloudbaseFunctionCaller } from './cloudbase' export const FEEDBACK_FUNCTION = 'cook-feedback' @@ -26,7 +26,7 @@ interface FunctionResult { } /** - * Talks to the `cook-feedback` cloud function. Signing in is optional: the + * Talks to the `cook-feedback` cloud function. Callers are anonymous: the * function falls back to the random device id to deduplicate and rate limit. */ export function createFeedbackRemote(callFunction: CloudbaseFunctionCaller, deviceId: string) { diff --git a/apps/web/app/adapters/userData.ts b/apps/web/app/adapters/userData.ts deleted file mode 100644 index dcf76a6..0000000 --- a/apps/web/app/adapters/userData.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { CloudbaseFunctionCaller } from './yunlefunAuth' -import type { UserDataRemote } from '~/utils/userDataSync' - -export const USER_DATA_FUNCTION = 'cook-user-data' - -interface FunctionResult { - code?: string - document?: unknown - ok?: boolean -} - -/** - * Talks to the `cook-user-data` cloud function, which stores one document per - * account. The function takes the account from the verified session, so the - * client never names a uid. - */ -export function createCloudbaseUserDataRemote(callFunction: CloudbaseFunctionCaller): UserDataRemote { - async function call(data: Record): Promise { - const result = await callFunction(USER_DATA_FUNCTION, data) as FunctionResult | null - if (!result || result.ok !== true) - throw new Error(`${USER_DATA_FUNCTION} failed: ${result?.code ?? 'unknown'}`) - return result - } - - return { - read: async () => (await call({ action: 'get' })).document ?? null, - write: async (_uid, document) => { - await call({ action: 'set', document: { ...document } }) - }, - } -} diff --git a/apps/web/app/adapters/yunlefunAuth.ts b/apps/web/app/adapters/yunlefunAuth.ts deleted file mode 100644 index 89af55d..0000000 --- a/apps/web/app/adapters/yunlefunAuth.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { - AcquisitionContext, - SsoAuthorizationResult, -} from '@yunlefun/sso' -import type { - YunlefunAccount, - YunlefunAuthorizationAdapter, - YunlefunIdentityAdapter, -} from '~/utils/yunlefunAuth' -import type { YunlefunSsoConfig } from '~/utils/yunlefunSsoConfig' -import { - adoptSsoCode, - consumeSsoRedirect, - startSsoRedirect, -} from '@yunlefun/sso' -import { - requestHostSsoAuthorization, - SsoIdentityAdoptionError, -} from '@yunlefun/sso/browser' -import { readCookAcquisition } from '~/utils/acquisition' - -interface CloudbaseUser { - avatar_url?: string - email?: string - id?: string - is_anonymous?: boolean - name?: string - phone?: string - phone_number?: string - picture?: string - sub?: string - uid?: string - user_metadata?: Record - username?: string -} - -interface CloudbaseSession { - access_token?: string - user?: CloudbaseUser -} - -export interface CloudbaseAuthClient { - getSession: () => Promise<{ - data?: { session?: CloudbaseSession | null } - error?: unknown - }> - getUser: () => Promise<{ - data?: { user?: CloudbaseUser | null } - error?: unknown - }> - signInWithCustomTicket: (getTicket: () => Promise) => Promise - signOut: () => Promise<{ error?: unknown } | void> -} - -/** Calls a cloud function with the signed-in session and resolves its `result`. */ -export type CloudbaseFunctionCaller = (name: string, data: Record) => Promise - -interface CloudbaseApp { - auth: (options?: { persistence: 'session' }) => CloudbaseAuthClient - callFunction: (options: { name: string, data: Record }) => Promise<{ - errCode?: number | string - errMsg?: string - result?: unknown - } | undefined> -} - -interface CloudbaseModule { - default?: { - init: (config: { - auth: { detectSessionInUrl: false } - env: string - region: 'ap-shanghai' - }) => CloudbaseApp - } - init?: (config: { - auth: { detectSessionInUrl: false } - env: string - region: 'ap-shanghai' - }) => CloudbaseApp -} - -export interface CloudbaseIdentityDependencies { - getAuth?: (env: string) => Promise -} - -export interface WebYunlefunAuthorizationDependencies { - requestHostAuthorization?: typeof requestHostSsoAuthorization - startRedirect?: typeof startSsoRedirect - readAcquisition?: () => AcquisitionContext | null -} - -let cachedCloudbaseEnv = '' -let cachedCloudbaseApp: Promise | undefined -let cachedCloudbaseAuth: CloudbaseAuthClient | undefined - -export function createWebYunlefunAuthorizationAdapter( - config: YunlefunSsoConfig, - dependencies: WebYunlefunAuthorizationDependencies = {}, -): YunlefunAuthorizationAdapter { - const requestHostAuthorization - = dependencies.requestHostAuthorization ?? requestHostSsoAuthorization - const startRedirect = dependencies.startRedirect ?? startSsoRedirect - - return { - consumeInitial: async () => consumeSsoRedirect(), - start: async () => { - const acquisition = (dependencies.readAcquisition ?? readCookAcquisition)() - const redirect = { ...config.redirect, ...(acquisition ? { acquisition } : {}) } - try { - const authorization = await requestHostAuthorization({ - ...redirect, - prompt: 'consent', - }) - if (authorization) - return authorization - } - catch (error) { - if (error instanceof SsoIdentityAdoptionError - && error.reason === 'access_denied') { - return { - ok: false, - reason: 'access_denied', - } - } - throw error - } - - await startRedirect(redirect) - return null - }, - } -} - -export function createCloudbaseIdentityAdapter( - config: YunlefunSsoConfig, - dependencies: CloudbaseIdentityDependencies = {}, -): YunlefunIdentityAdapter { - const loadAuth = dependencies.getAuth ?? getCloudbaseAuth - return { - adopt: async (authorization: SsoAuthorizationResult) => { - const auth = await loadAuth(config.cloudbaseEnv) - const adopted = await adoptSsoCode(auth, authorization, config.exchange) - return adopted ? readAuthenticatedAccount(auth) : null - }, - restore: async () => { - const auth = await loadAuth(config.cloudbaseEnv) - return readAuthenticatedAccount(auth) - }, - signOut: async () => { - const auth = await loadAuth(config.cloudbaseEnv) - const result = await auth.signOut() - if (result?.error) - throw new Error('CloudBase sign-out failed') - }, - } -} - -function getCloudbaseApp(env: string): Promise { - if (cachedCloudbaseApp) { - if (cachedCloudbaseEnv !== env) - return Promise.reject(new Error('CloudBase environment cannot change at runtime')) - return cachedCloudbaseApp - } - - cachedCloudbaseEnv = env - cachedCloudbaseApp = (async () => { - const cloudbaseModule = await import('@cloudbase/js-sdk') as unknown as CloudbaseModule - const cloudbase = cloudbaseModule.default ?? cloudbaseModule - if (!cloudbase.init) - throw new Error('@cloudbase/js-sdk does not expose init()') - - return cloudbase.init({ - auth: { detectSessionInUrl: false }, - env, - region: 'ap-shanghai', - }) - })() - cachedCloudbaseApp.catch(() => { - cachedCloudbaseApp = undefined - }) - return cachedCloudbaseApp -} - -async function getCloudbaseAuth(env: string): Promise { - const app = await getCloudbaseApp(env) - cachedCloudbaseAuth ??= app.auth({ persistence: 'session' }) - return cachedCloudbaseAuth -} - -/** Call a cloud function on the same CloudBase app that holds the SSO session. */ -export async function callCloudbaseFunction( - env: string, - name: string, - data: Record, -): Promise { - // Initialise auth first so the call carries the signed-in session. - await getCloudbaseAuth(env) - const app = await getCloudbaseApp(env) - const response = await app.callFunction({ name, data }) - // A missing result means the call itself failed (not deployed, crashed, …). - if (!response || response.result === undefined) - throw new Error(`CloudBase function ${name} failed: ${response?.errCode ?? response?.errMsg ?? 'no result'}`) - return response.result -} - -async function readAuthenticatedAccount(auth: CloudbaseAuthClient): Promise { - const sessionResult = await auth.getSession() - const session = sessionResult.data?.session - if (sessionResult.error - || !session - || typeof session.access_token !== 'string' - || session.access_token.length < 16 - || session.user?.is_anonymous) { - return null - } - - const userResult = await auth.getUser() - if (userResult.error) - return null - return normalizeAccount(userResult.data?.user ?? session.user) -} - -function normalizeAccount(user: CloudbaseUser | null | undefined): YunlefunAccount | null { - const uid = firstString(user?.id, user?.uid, user?.sub) - if (!user || user.is_anonymous || !uid) - return null - - const metadata = user.user_metadata ?? {} - const displayName = firstString( - metadata.displayName, - metadata.name, - metadata.nickName, - metadata.username, - user.name, - user.username, - user.email, - user.phone, - user.phone_number, - uid.slice(0, 8), - ) - const avatarUrl = firstString( - metadata.avatarUrl, - metadata.avatar_url, - metadata.picture, - user.avatar_url, - user.picture, - ) - - return { - uid, - displayName: displayName ?? uid.slice(0, 8), - ...(avatarUrl ? { avatarUrl } : {}), - } -} - -function firstString(...values: unknown[]): string | undefined { - return values.find(value => typeof value === 'string' && value.trim())?.toString().trim() -} diff --git a/apps/web/app/components/TheAboutList.vue b/apps/web/app/components/TheAboutList.vue deleted file mode 100644 index 04324d2..0000000 --- a/apps/web/app/components/TheAboutList.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - diff --git a/apps/web/app/composables/store/customRecipe.ts b/apps/web/app/composables/store/customRecipe.ts index 24d8c85..1f2829a 100644 --- a/apps/web/app/composables/store/customRecipe.ts +++ b/apps/web/app/composables/store/customRecipe.ts @@ -4,7 +4,7 @@ import { namespace } from '~/constants' export type { CustomRecipe } -// 和收藏、历史一样始终保存在本机;不参与云同步 +// 和收藏、历史一样始终保存在本机 export const customRecipes = useStorage(`${namespace}:custom-recipes`, []) export function getCustomRecipe(customId: string) { diff --git a/apps/web/app/composables/store/userData.ts b/apps/web/app/composables/store/userData.ts index 655bbe3..8196e4a 100644 --- a/apps/web/app/composables/store/userData.ts +++ b/apps/web/app/composables/store/userData.ts @@ -1,5 +1,4 @@ import type { UserData, UserDataExportFile } from '~/utils/userData' -import type { UserDataLocal } from '~/utils/userDataSync' import { createEmptyUserData, createUserDataExport, @@ -32,22 +31,12 @@ export function writeLocalUserData(data: UserData): void { } } -export const localUserData: UserDataLocal = { - read: readLocalUserData, - write: writeLocalUserData, - readLastUid: () => userDataMeta.value.lastUid ?? '', - writeLastUid: (uid) => { - if (userDataMeta.value.lastUid !== uid) - userDataMeta.value = { ...userDataMeta.value, lastUid: uid } - }, -} - export function exportLocalUserData(now = Date.now()): UserDataExportFile { return createUserDataExport(mergeUserData(readLocalUserData(), createEmptyUserData(), now), now) } /** - * Merge an exported file into local data, using the same rules as cloud sync. + * Merge an exported file into local data. * Returns null when the text is not a Cook export. */ export function importLocalUserData(text: string, now = Date.now()): { favorites: number, histories: number } | null { diff --git a/apps/web/app/composables/store/userDataMeta.ts b/apps/web/app/composables/store/userDataMeta.ts index 37cafaf..c773640 100644 --- a/apps/web/app/composables/store/userDataMeta.ts +++ b/apps/web/app/composables/store/userDataMeta.ts @@ -3,21 +3,18 @@ import { useStorage } from '@vueuse/core' import { namespace } from '~/constants' /** - * Sync bookkeeping kept next to `cook:favorites` and `cook:history`, which stay - * in their original format. Deletions are recorded even when logged out so they - * can be applied to the cloud after the next login. + * Deletion bookkeeping kept next to `cook:favorites` and `cook:history`, which + * stay in their original format. Deletions are recorded so they survive an + * export/import round trip instead of resurrecting removed records. */ export interface UserDataMeta { favoriteRemovals: FavoriteRemoval[] historyRemovals: HistoryRemoval[] historyClearedAt: number - /** The account this device last synced with. */ - lastUid: string } export const userDataMeta = useStorage(`${namespace}:sync`, { favoriteRemovals: [], historyRemovals: [], historyClearedAt: 0, - lastUid: '', }, undefined, { mergeDefaults: true }) diff --git a/apps/web/app/composables/useRecipeFeedback.ts b/apps/web/app/composables/useRecipeFeedback.ts index 21b558d..af3d56e 100644 --- a/apps/web/app/composables/useRecipeFeedback.ts +++ b/apps/web/app/composables/useRecipeFeedback.ts @@ -1,4 +1,6 @@ +import type { CloudbaseFunctionCaller } from '~/adapters/cloudbase' import type { RecipeReport, SearchSignal } from '~/adapters/feedback' +import { callCloudbaseFunction } from '~/adapters/cloudbase' import { createFeedbackRemote } from '~/adapters/feedback' import { getDeviceId, signalKey } from '~/utils/feedback' @@ -9,8 +11,11 @@ const sentSignals = new Set() * cloud function. Signals are best effort and never surface errors. */ export function useRecipeFeedback() { - const auth = useYunlefunAuth() - const remote = () => createFeedbackRemote(auth.callFunction, getDeviceId()) + const config = useRuntimeConfig() + const env = config.public.yunlefunCloudbaseEnv + const callFunction: CloudbaseFunctionCaller = (name, data) => + callCloudbaseFunction(env, name, data) + const remote = () => createFeedbackRemote(callFunction, getDeviceId()) async function report(input: RecipeReport): Promise { await remote().report(input) diff --git a/apps/web/app/composables/useUserDataSync.ts b/apps/web/app/composables/useUserDataSync.ts deleted file mode 100644 index 9644fef..0000000 --- a/apps/web/app/composables/useUserDataSync.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { UserDataLocal, UserDataSyncScheduler } from '~/utils/userDataSync' -import { createCloudbaseUserDataRemote } from '~/adapters/userData' -import { favoriteEntries } from '~/composables/store/favorite' -import { recipeHistories } from '~/composables/store/history' -import { localUserData } from '~/composables/store/userData' -import { userDataMeta } from '~/composables/store/userDataMeta' -import { - createUserDataSyncScheduler, - syncUserData, - UserDataSyncError, -} from '~/utils/userDataSync' - -export type UserDataSyncStatus = 'idle' | 'syncing' | 'synced' | 'error' - -interface UserDataSyncState { - errorMessage: string - lastSyncedAt: number - status: UserDataSyncStatus -} - -/** Wait this long after a local change before pushing it. */ -const LOCAL_CHANGE_DELAY = 2000 - -let scheduler: UserDataSyncScheduler | undefined - -/** - * Keeps favorites and history in sync with the signed-in 云乐坊 account. - * Logged out, nothing runs and local data behaves as before. - */ -export function useUserDataSync() { - const state = useState('cook:user-data-sync', () => ({ - errorMessage: '', - lastSyncedAt: 0, - status: 'idle', - })) - - function install(): void { - if (!import.meta.client || scheduler) - return - - const auth = useYunlefunAuth() - const uid = computed(() => auth.isAuthenticated.value ? auth.account.value?.uid ?? '' : '') - const remote = createCloudbaseUserDataRemote(auth.callFunction) - - // Local writes made by a sync round must not schedule another round. - let applyingRemote = false - const local: UserDataLocal = { - ...localUserData, - write: (data) => { - applyingRemote = true - try { - localUserData.write(data) - } - finally { - applyingRemote = false - } - }, - } - - const activeScheduler = createUserDataSyncScheduler({ - run: async () => { - const current = uid.value - if (!current) - return - state.value = { ...state.value, errorMessage: '', status: 'syncing' } - await syncUserData(current, local, remote) - if (uid.value === current) - state.value = { errorMessage: '', lastSyncedAt: Date.now(), status: 'synced' } - }, - onError: (error) => { - if (!uid.value) - return - state.value = { - ...state.value, - errorMessage: error instanceof UserDataSyncError ? error.message : '云端同步失败', - status: 'error', - } - }, - shouldRetry: error => !(error instanceof UserDataSyncError && error.code === 'invalid_remote'), - }) - scheduler = activeScheduler - - watch(uid, (current) => { - if (current) { - activeScheduler.request() - return - } - activeScheduler.stop() - state.value = { errorMessage: '', lastSyncedAt: 0, status: 'idle' } - }, { immediate: true }) - - watch( - () => [ - favoriteEntries.value, - recipeHistories.value, - userDataMeta.value.favoriteRemovals, - userDataMeta.value.historyRemovals, - userDataMeta.value.historyClearedAt, - ], - () => { - if (!applyingRemote && uid.value) - activeScheduler.request(LOCAL_CHANGE_DELAY) - }, - { deep: true, flush: 'sync' }, - ) - - // Pull changes made on other devices when coming back to the app. - const pull = () => { - if (uid.value && document.visibilityState === 'visible') - activeScheduler.request() - } - document.addEventListener('visibilitychange', pull) - window.addEventListener('online', pull) - } - - function syncNow(): void { - scheduler?.request() - } - - return { - errorMessage: computed(() => state.value.errorMessage), - install, - lastSyncedAt: computed(() => state.value.lastSyncedAt), - status: computed(() => state.value.status), - syncNow, - } -} diff --git a/apps/web/app/composables/useYunlefunAuth.ts b/apps/web/app/composables/useYunlefunAuth.ts deleted file mode 100644 index dd0807f..0000000 --- a/apps/web/app/composables/useYunlefunAuth.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { - YunlefunAuthController, - YunlefunAuthSnapshot, -} from '~/utils/yunlefunAuth' -import { - callCloudbaseFunction, - createCloudbaseIdentityAdapter, - createWebYunlefunAuthorizationAdapter, -} from '~/adapters/yunlefunAuth' -import { createYunlefunAuthController } from '~/utils/yunlefunAuth' -import { resolveYunlefunSsoConfig } from '~/utils/yunlefunSsoConfig' - -interface YunlefunRuntimeConfig { - public: { - yunlefunCloudbaseEnv?: string - yunlefunSsoClientId?: string - yunlefunSsoExchangeUrl?: string - yunlefunSsoOrigin?: string - yunlefunSsoRedirectUri?: string - yunlefunSsoRedirectUris?: string - yunlefunSsoScope?: string - } -} - -let controller: YunlefunAuthController | undefined -let cloudbaseEnv = '' -let initialization: Promise | undefined - -export function useYunlefunAuth() { - const runtimeConfig = useRuntimeConfig() as unknown as YunlefunRuntimeConfig - const snapshot = useState('yunlefun:auth:snapshot', () => ({ - account: null, - errorMessage: '', - status: 'idle', - })) - const configurationMessage = useState('yunlefun:auth:configuration-message', () => '') - const isConfigured = computed(() => !configurationMessage.value) - const isAuthenticated = computed(() => snapshot.value.status === 'signed-in' && Boolean(snapshot.value.account)) - - async function initialize(): Promise { - if (!initialization) - initialization = initializeOnce(runtimeConfig, snapshot, configurationMessage) - await initialization - publishSnapshot(snapshot) - } - - async function signIn(): Promise { - if (!controller || !isConfigured.value) - return - await controller.signIn() - publishSnapshot(snapshot) - } - - async function signOut(): Promise { - if (!controller) - return - await controller.signOut() - publishSnapshot(snapshot) - } - - /** Call a CloudBase cloud function as the signed-in account. */ - async function callFunction(name: string, data: Record): Promise { - if (!cloudbaseEnv) - throw new Error('CloudBase is not configured') - return callCloudbaseFunction(cloudbaseEnv, name, data) - } - - return { - account: computed(() => snapshot.value.account), - configurationMessage: readonly(configurationMessage), - errorMessage: computed(() => snapshot.value.errorMessage), - callFunction, - initialize, - isAuthenticated, - isConfigured, - signIn, - signOut, - status: computed(() => snapshot.value.status), - } -} - -async function initializeOnce( - runtimeConfig: YunlefunRuntimeConfig, - snapshot: Ref, - configurationMessage: Ref, -): Promise { - if (!import.meta.client) - return - - const result = resolveYunlefunSsoConfig({ - clientId: runtimeConfig.public.yunlefunSsoClientId, - cloudbaseEnv: runtimeConfig.public.yunlefunCloudbaseEnv, - exchangeUrl: runtimeConfig.public.yunlefunSsoExchangeUrl, - redirectUri: runtimeConfig.public.yunlefunSsoRedirectUri, - redirectUris: runtimeConfig.public.yunlefunSsoRedirectUris, - scope: runtimeConfig.public.yunlefunSsoScope, - ssoOrigin: runtimeConfig.public.yunlefunSsoOrigin, - }, { - currentOrigin: window.location.origin, - }) - - if (!result.ok) { - configurationMessage.value = result.message - snapshot.value = { - account: null, - errorMessage: '', - status: 'signed-out', - } - return - } - - cloudbaseEnv = result.config.cloudbaseEnv - controller = createYunlefunAuthController({ - identity: createCloudbaseIdentityAdapter(result.config), - web: createWebYunlefunAuthorizationAdapter(result.config), - }) - - await controller.initialize() - publishSnapshot(snapshot) - if (window.location.pathname === '/auth/callback' - && controller.snapshot().status === 'signed-in') { - await navigateTo('/my', { replace: true }) - } -} - -function publishSnapshot(snapshot: Ref): void { - if (controller) - snapshot.value = controller.snapshot() -} diff --git a/apps/web/app/constants/acknowledgements.ts b/apps/web/app/constants/acknowledgements.ts deleted file mode 100644 index b545698..0000000 --- a/apps/web/app/constants/acknowledgements.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * 致谢名单配置 - */ - -export interface Acknowledgement { - /** - * 描述或贡献说明 - */ - description?: string - /** - * 头像或Logo URL - */ - avatar?: string - /** - * 社交媒体链接 - */ - links?: { - /** - * 链接类型(用于显示图标) - */ - type?: 'github' | 'bilibili' | 'weibo' | 'twitter' | 'wechat' | 'blog' | 'website' | 'email' - /** - * 显示文本 - */ - label: string - /** - * 链接地址 - */ - href: string - /** - * 打开方式 - */ - target?: '_blank' | '_self' - }[] -} - -/** - * 个人致谢名单(简单列表) - */ -export interface PersonalAcknowledgement { - /** - * 姓名 - */ - name: string - /** - * 个人链接(可选) - */ - link?: string -} - -/** - * 致谢名单 - */ -export const acknowledgements: Acknowledgement[] = [ - { - description: '提供优质的菜谱数据来源', - links: [ - { - type: 'bilibili', - label: '哔哩哔哩 美食专区', - href: 'https://www.bilibili.com/blackboard/dynamic/306882', - target: '_blank', - }, - ], - }, - { - description: '感谢所有为本项目贡献代码、提出建议的开发者们', - links: [ - { - type: 'github', - label: 'Contributors', - href: 'https://github.com/YunYouJun/cook/graphs/contributors', - target: '_blank', - }, - ], - }, - { - description: '以下开源项目使得项目得以快速实现', - links: [ - { - type: 'website', - label: 'Nuxt', - href: 'https://nuxt.com', - target: '_blank', - }, - { - type: 'website', - label: 'Vue', - href: 'https://vuejs.org', - target: '_blank', - }, - { - type: 'website', - label: 'UnoCSS', - href: 'https://unocss.dev', - target: '_blank', - }, - { - type: 'website', - label: 'Ionic Framework', - href: 'https://ionicframework.com', - target: '_blank', - }, - { - type: 'website', - label: 'VueUse', - href: 'https://vueuse.org', - target: '_blank', - }, - // 其他 - { - type: 'website', - label: '其他', - href: 'https://github.com/YunYouJun/cook/blob/main/package.json', - }, - ], - }, -] - -/** - * 个人致谢名单 - */ -export const personalAcknowledgements: PersonalAcknowledgement[] = [ - { - name: 'Runny', - link: 'https://weibo.com/runny', - }, - { - name: '麒麟', - }, - { - name: '晴方啾', - }, - { - name: '课代表阿伟', - }, -] diff --git a/apps/web/app/constants/index.ts b/apps/web/app/constants/index.ts index 49abc28..2767c5f 100644 --- a/apps/web/app/constants/index.ts +++ b/apps/web/app/constants/index.ts @@ -6,7 +6,6 @@ export const lastDbUpdated = '2026-10-21 12:41:15' export const icp = '苏ICP备17038157号' -export * from './acknowledgements' export * from './links' export const ionDarkClass = 'ion-palette-dark' diff --git a/apps/web/app/pages/about/acknowledgements.vue b/apps/web/app/pages/about/acknowledgements.vue deleted file mode 100644 index d919420..0000000 --- a/apps/web/app/pages/about/acknowledgements.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - diff --git a/apps/web/app/pages/about/index.vue b/apps/web/app/pages/about/index.vue index 75afa75..f43ace0 100644 --- a/apps/web/app/pages/about/index.vue +++ b/apps/web/app/pages/about/index.vue @@ -1,7 +1,7 @@ - - diff --git a/apps/web/app/pages/auth/callback.vue b/apps/web/app/pages/auth/callback.vue deleted file mode 100644 index 5210875..0000000 --- a/apps/web/app/pages/auth/callback.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/apps/web/app/pages/help.vue b/apps/web/app/pages/help.vue index 5d1ad42..df2fd9b 100644 --- a/apps/web/app/pages/help.vue +++ b/apps/web/app/pages/help.vue @@ -21,7 +21,7 @@ const faqs = [ }, { q: '收藏和历史存在哪?', - a: '存在这台设备的浏览器本地存储里,不上传服务器。换个浏览器或清了浏览器数据就没了。登录后可以同步到你的云乐坊账号。', + a: '存在这台设备的浏览器本地存储里,不上传服务器。换个浏览器或清了浏览器数据就没了,可以在设置页导出后再导入到另一台设备。', }, { q: '菜谱里的用量和调味在哪看?', diff --git a/apps/web/app/pages/settings.vue b/apps/web/app/pages/settings.vue index 55332f1..d90526f 100644 --- a/apps/web/app/pages/settings.vue +++ b/apps/web/app/pages/settings.vue @@ -292,7 +292,7 @@ async function importFromPaste(): Promise { -import dayjs from 'dayjs' import { customRecipes } from '~/composables/store/customRecipe' import { favoriteEntries } from '~/composables/store/favorite' import { recipeHistories } from '~/composables/store/history' @@ -9,60 +8,14 @@ definePageMeta({ alias: ['/my'], }) -const { - account, - configurationMessage, - errorMessage, - isAuthenticated, - signIn, - signOut, - status, -} = useYunlefunAuth() - -const authBusy = computed(() => status.value === 'checking' || status.value === 'signing-in') - -const { - errorMessage: syncErrorMessage, - lastSyncedAt, - status: syncStatus, - syncNow, -} = useUserDataSync() - -const syncText = computed(() => { - switch (syncStatus.value) { - case 'syncing': - return '正在同步…' - case 'synced': - return `已同步 ${dayjs(lastSyncedAt.value).format('HH:mm')}` - case 'error': - return `${syncErrorMessage.value},点击重试` - default: - return '等待同步' - } -}) - const rStore = useRecipeStore() const favCount = computed(() => favoriteEntries.value.length) const historyCount = computed(() => recipeHistories.value.length) const mineCount = computed(() => customRecipes.value.length) -const syncedText = computed(() => lastSyncedAt.value ? dayjs(lastSyncedAt.value).format('MM-DD HH:mm') : '—') const pantryText = computed(() => rStore.selectedPantry.join('、') || '无') const avoidText = computed(() => rStore.selectedAvoid.join('、') || '无') const spicyText = computed(() => rStore.noSpicy ? '开' : '关') - -function handleSyncClick(): void { - if (syncStatus.value === 'error') - syncNow() -} - -async function handleSignIn(): Promise { - await signIn() -} - -async function handleSignOut(): Promise { - await signOut() -} - - diff --git a/apps/web/app/plugins/acquisition.client.ts b/apps/web/app/plugins/acquisition.client.ts deleted file mode 100644 index 521cbee..0000000 --- a/apps/web/app/plugins/acquisition.client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { captureCookAcquisition } from '~/utils/acquisition' - -export default defineNuxtPlugin({ - name: 'cook-acquisition', - enforce: 'pre', - setup() { - captureCookAcquisition() - }, -}) diff --git a/apps/web/app/plugins/user-data-sync.client.ts b/apps/web/app/plugins/user-data-sync.client.ts deleted file mode 100644 index d90890a..0000000 --- a/apps/web/app/plugins/user-data-sync.client.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default defineNuxtPlugin({ - name: 'cook-user-data-sync', - dependsOn: ['cook-auth'], - setup() { - useUserDataSync().install() - }, -}) diff --git a/apps/web/app/plugins/yunlefun-auth.client.ts b/apps/web/app/plugins/yunlefun-auth.client.ts deleted file mode 100644 index 89dfaf3..0000000 --- a/apps/web/app/plugins/yunlefun-auth.client.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default defineNuxtPlugin({ - name: 'cook-auth', - dependsOn: ['cook-acquisition'], - setup() { - void useYunlefunAuth().initialize() - }, -}) diff --git a/apps/web/app/utils/acquisition.ts b/apps/web/app/utils/acquisition.ts deleted file mode 100644 index 35ea037..0000000 --- a/apps/web/app/utils/acquisition.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { AcquisitionContext, AcquisitionTouch } from '@yunlefun/sso' -import { sanitizeAcquisitionContext } from '@yunlefun/sso' - -const FIRST_TOUCH_KEY = 'cook:acquisition:v1:first-touch' -const REGISTER_TOUCH_KEY = 'cook:acquisition:v1:register-touch' -const FIRST_TOUCH_TTL_MS = 30 * 24 * 60 * 60 * 1000 -const UTM_RE = /^[\p{L}\p{N}\p{M} ._/-]*$/u -let fallback: AcquisitionContext | null = null -const pendingWrites = new Map() - -type AcquisitionBrowser = Pick - -/** Called once at document entry, before auth can consume or redirect a callback. */ -export function captureCookAcquisition( - browser: AcquisitionBrowser | undefined = typeof window === 'undefined' ? undefined : window, - now = Date.now(), -): AcquisitionContext | null { - if (!browser) - return null - try { - if (/^\/auth(?:\/|$)/.test(browser.location.pathname)) - return readCookAcquisition(browser, now) - const query = new URLSearchParams(browser.location.search) - const touch = readTouch({ - capturedAt: now, - landingHost: browser.location.hostname, - landingPath: browser.location.pathname, - referrerHost: readReferrerHost(browser.document.referrer, browser.location.hostname), - utmSource: readUtm(query.get('utm_source')), - utmMedium: readUtm(query.get('utm_medium')), - utmCampaign: readUtm(query.get('utm_campaign')), - }, now) - if (!touch) - return null - const firstRead = readStoredTouch(browser, 'localStorage', FIRST_TOUCH_KEY, now) - const storedFirst = firstRead.accessible ? firstRead.touch : validFallbackTouch(fallback?.firstTouch, now) - const firstTouch = storedFirst && storedFirst.capturedAt + FIRST_TOUCH_TTL_MS > now - ? storedFirst - : touch - fallback = { version: 1, firstTouch, registerTouch: touch } - writeStoredTouch(browser, 'localStorage', FIRST_TOUCH_KEY, firstTouch) - writeStoredTouch(browser, 'sessionStorage', REGISTER_TOUCH_KEY, touch) - return fallback - } - catch { - return readCookAcquisition(browser, now) - } -} - -/** Read at auth start so both host and redirect paths use the same entry snapshot. */ -export function readCookAcquisition( - browser: AcquisitionBrowser | undefined = typeof window === 'undefined' ? undefined : window, - now = Date.now(), -): AcquisitionContext | null { - if (!browser) - return null - const registerRead = readStoredTouch(browser, 'sessionStorage', REGISTER_TOUCH_KEY, now) - const registerTouch = registerRead.accessible ? registerRead.touch : validFallbackTouch(fallback?.registerTouch, now) - if (!registerTouch) { - // Readable deletion/corruption is authoritative; never resurrect an old campaign from memory. - fallback = null - return null - } - const firstRead = readStoredTouch(browser, 'localStorage', FIRST_TOUCH_KEY, now) - const storedFirst = firstRead.accessible ? firstRead.touch : validFallbackTouch(fallback?.firstTouch, now) - const firstTouch = storedFirst && storedFirst.capturedAt + FIRST_TOUCH_TTL_MS > now - ? storedFirst - : registerTouch - fallback = sanitizeAcquisitionContext({ version: 1, firstTouch, registerTouch }) - return fallback -} - -function readTouch(input: unknown, now: number): AcquisitionTouch | null { - const touch = sanitizeAcquisitionContext({ version: 1, firstTouch: input, registerTouch: input })?.firstTouch - return touch && touch.capturedAt <= now && touch.capturedAt + FIRST_TOUCH_TTL_MS > now ? touch : null -} - -function validFallbackTouch(touch: AcquisitionTouch | undefined, now: number): AcquisitionTouch | null { - return touch ? readTouch(touch, now) : null -} - -function readStoredTouch(browser: AcquisitionBrowser, storage: 'localStorage' | 'sessionStorage', key: string, now: number) { - let value: string | null - try { - value = browser[storage].getItem(key) - } - catch { - return { accessible: false, touch: null } - } - const pending = pendingWrites.get(key) - if (pending && value === pending.previousValue) { - // Quota failures can leave the previous campaign readable. Prefer the new - // document's unpersisted touch while that exact previous value is unchanged. - const touch = readTouch(pending.touch, now) - if (!touch) - pendingWrites.delete(key) - return { accessible: true, touch } - } - pendingWrites.delete(key) - try { - return { accessible: true, touch: value ? readTouch(JSON.parse(value), now) : null } - } - catch { - return { accessible: true, touch: null } - } -} - -function writeStoredTouch(browser: AcquisitionBrowser, storage: 'localStorage' | 'sessionStorage', key: string, touch: AcquisitionTouch) { - let previousValue: string | null | undefined - try { - previousValue = browser[storage].getItem(key) - } - catch { - // The fallback is validated separately when storage access is denied. - } - try { - browser[storage].setItem(key, JSON.stringify(touch)) - pendingWrites.delete(key) - } - catch { - // Storage restrictions affect attribution only; auth must remain usable. - pendingWrites.set(key, { previousValue, touch }) - } -} - -function readReferrerHost(input: string, landingHost: string): string { - try { - const url = new URL(input) - const host = url.hostname.replace(/\.$/, '') - const internal = host === landingHost || host === 'yunle.fun' - || host.endsWith('.yunle.fun') || host === 'cook.yunyoujun.cn' - return (url.protocol === 'https:' || url.protocol === 'http:') - && !url.username && !url.password && !internal - ? host - : '' - } - catch { - return '' - } -} - -function readUtm(input: string | null): string { - const value = input?.trim() ?? '' - return value.length <= 120 && UTM_RE.test(value) ? value : '' -} diff --git a/apps/web/app/utils/userDataSync.ts b/apps/web/app/utils/userDataSync.ts deleted file mode 100644 index 863f07e..0000000 --- a/apps/web/app/utils/userDataSync.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { UserData } from './userData' -import { - createEmptyUserData, - isSameUserData, - mergeUserData, - normalizeUserData, - USER_DATA_VERSION, -} from './userData' - -export interface UserDataDocument extends UserData { - version: typeof USER_DATA_VERSION - updatedAt: number -} - -export interface UserDataRemote { - /** Resolves null when the user has no document yet. */ - read: (uid: string) => Promise - write: (uid: string, document: UserDataDocument) => Promise -} - -export interface UserDataLocal { - read: () => UserData - write: (data: UserData) => void - /** The account this device last synced with. */ - readLastUid: () => string - writeLastUid: (uid: string) => void -} - -export class UserDataSyncError extends Error { - constructor(public readonly code: 'invalid_remote' | 'remote_failed', options?: { cause?: unknown }) { - super(code === 'invalid_remote' ? '云端数据格式无法识别' : '云端同步失败', options) - this.name = 'UserDataSyncError' - } -} - -/** - * One round of read → merge → write. - * - * - Same account (or a device that never synced): local and cloud are merged. - * - Another account synced here last: its data is already in its own cloud - * document, so the cloud replaces local instead of leaking into this account. - * - An unrecognised cloud document is never overwritten. - */ -export async function syncUserData( - uid: string, - local: UserDataLocal, - remote: UserDataRemote, - now = Date.now(), -): Promise { - let raw: unknown | null - try { - raw = await remote.read(uid) - } - catch (error) { - throw new UserDataSyncError('remote_failed', { cause: error }) - } - - const cloud = raw === null ? createEmptyUserData() : normalizeUserData(raw) - if (!cloud) - throw new UserDataSyncError('invalid_remote') - - const lastUid = local.readLastUid() - const current = local.read() - const base = lastUid && lastUid !== uid ? createEmptyUserData() : current - const merged = mergeUserData(base, cloud, now) - - if (!isSameUserData(merged, current)) - local.write(merged) - local.writeLastUid(uid) - - if (raw === null || !isSameUserData(merged, cloud)) { - try { - await remote.write(uid, { version: USER_DATA_VERSION, ...merged, updatedAt: now }) - } - catch (error) { - throw new UserDataSyncError('remote_failed', { cause: error }) - } - } - - return merged -} - -export interface UserDataSyncScheduler { - /** Run a sync after `delay` ms; repeated requests are coalesced. */ - request: (delay?: number) => void - stop: () => void -} - -export interface UserDataSyncSchedulerOptions { - run: () => Promise - onError?: (error: unknown) => void - /** Defaults to retrying every failure. */ - shouldRetry?: (error: unknown) => boolean - maxRetryDelay?: number -} - -/** - * Serialises sync rounds: at most one runs at a time, a request made while one - * is running triggers another round afterwards, and failures retry with - * exponential backoff until a round succeeds or `stop()` is called. - */ -export function createUserDataSyncScheduler(options: UserDataSyncSchedulerOptions): UserDataSyncScheduler { - const maxRetryDelay = options.maxRetryDelay ?? 30_000 - let timer: ReturnType | undefined - let running = false - let pending = false - let stopped = false - let retry = 0 - - function request(delay = 0): void { - stopped = false - if (running) { - pending = true - return - } - clearTimeout(timer) - timer = setTimeout(flush, delay) - } - - async function flush(): Promise { - timer = undefined - if (stopped || running) - return - running = true - pending = false - let retryDelay: number | undefined - try { - await options.run() - retry = 0 - } - catch (error) { - options.onError?.(error) - if (options.shouldRetry?.(error) ?? true) { - retryDelay = Math.min(maxRetryDelay, 1000 * 2 ** retry) - retry += 1 - } - } - finally { - running = false - } - if (stopped) - return - if (pending) - request() - else if (retryDelay !== undefined) - request(retryDelay) - } - - return { - request, - stop() { - stopped = true - pending = false - retry = 0 - clearTimeout(timer) - timer = undefined - }, - } -} diff --git a/apps/web/app/utils/yunlefunAuth.ts b/apps/web/app/utils/yunlefunAuth.ts deleted file mode 100644 index ed5c60b..0000000 --- a/apps/web/app/utils/yunlefunAuth.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { - SsoAuthorizationResult, - SsoRedirectResult, -} from '@yunlefun/sso' - -export interface YunlefunAccount { - avatarUrl?: string - displayName: string - uid: string -} - -export type YunlefunAuthStatus - = | 'checking' - | 'error' - | 'idle' - | 'signed-in' - | 'signed-out' - | 'signing-in' - -export interface YunlefunAuthSnapshot { - account: YunlefunAccount | null - errorMessage: string - status: YunlefunAuthStatus -} - -export type YunlefunAuthorizationResult - = | SsoAuthorizationResult - | Extract - -export interface YunlefunAuthorizationAdapter { - consumeInitial: () => Promise - start: () => Promise -} - -export interface YunlefunIdentityAdapter { - adopt: (authorization: SsoAuthorizationResult) => Promise - restore: () => Promise - signOut: () => Promise -} - -export interface YunlefunAuthController { - handleAuthorization: (authorization: YunlefunAuthorizationResult) => Promise - initialize: () => Promise - signIn: () => Promise - signOut: () => Promise - snapshot: () => Readonly -} - -interface YunlefunAuthControllerOptions { - identity: YunlefunIdentityAdapter - web: YunlefunAuthorizationAdapter -} - -export function createYunlefunAuthController( - options: YunlefunAuthControllerOptions, -): YunlefunAuthController { - const authorization = options.web - let initialized = false - let state: YunlefunAuthSnapshot = { - account: null, - errorMessage: '', - status: 'idle', - } - - async function initialize(): Promise { - if (initialized) - return - - initialized = true - update({ status: 'checking' }) - - try { - const result = await authorization.consumeInitial() - if (result) { - if (!result.ok) { - applyFailure(result.reason) - return - } - - await adopt(result) - return - } - - const account = await options.identity.restore() - update({ - account, - status: account ? 'signed-in' : 'signed-out', - }) - } - catch { - update({ - account: null, - errorMessage: '登录状态检查失败,请稍后重试', - status: 'error', - }) - } - } - - async function signIn(): Promise { - update({ - errorMessage: '', - status: 'signing-in', - }) - try { - const result = await authorization.start() - if (result) - await handleAuthorization(result) - } - catch { - update({ - errorMessage: '无法打开登录授权,请稍后重试', - status: 'error', - }) - } - } - - async function handleAuthorization(result: YunlefunAuthorizationResult): Promise { - if (!result.ok) { - applyFailure(result.reason) - return - } - - try { - await adopt(result) - } - catch { - update({ - account: null, - errorMessage: '登录身份验证失败,请重新授权', - status: 'error', - }) - } - } - - async function signOut(): Promise { - try { - await options.identity.signOut() - update({ - account: null, - errorMessage: '', - status: 'signed-out', - }) - } - catch { - update({ - errorMessage: '退出登录失败,请稍后重试', - status: 'error', - }) - } - } - - async function adopt(result: SsoAuthorizationResult): Promise { - const account = await options.identity.adopt(result) - if (!account) - throw new Error('SSO identity is unavailable') - - update({ - account, - errorMessage: '', - status: 'signed-in', - }) - } - - function applyFailure(reason: Extract['reason']): void { - if (reason === 'not_authenticated') { - update({ - account: null, - errorMessage: '', - status: 'signed-out', - }) - return - } - - const messages = { - access_denied: '登录授权已取消', - invalid_request: '登录请求无效,请刷新后重试', - server_error: '登录服务响应异常,请稍后重试', - } as const - update({ - account: null, - errorMessage: messages[reason], - status: 'error', - }) - } - - function update(patch: Partial): void { - state = { ...state, ...patch } - } - - return { - handleAuthorization, - initialize, - signIn, - signOut, - snapshot: () => Object.freeze({ ...state }), - } -} diff --git a/apps/web/app/utils/yunlefunSsoConfig.ts b/apps/web/app/utils/yunlefunSsoConfig.ts deleted file mode 100644 index 26ff4e1..0000000 --- a/apps/web/app/utils/yunlefunSsoConfig.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { RedirectSsoOptions, SsoCodeExchangeOptions } from '@yunlefun/sso' -import { - readSsoClientId, - readSsoRedirectUri, - readSsoScope, -} from '@yunlefun/sso' - -export interface YunlefunSsoConfigInput { - clientId?: string - cloudbaseEnv?: string - exchangeUrl?: string - redirectUri?: string - redirectUris?: string | string[] - scope?: string - ssoOrigin?: string -} - -export interface YunlefunSsoConfigContext { - currentOrigin: string -} - -export interface YunlefunSsoConfig { - cloudbaseEnv: string - exchange: SsoCodeExchangeOptions - redirect: RedirectSsoOptions -} - -export type YunlefunSsoConfigResult - = | { ok: true, config: YunlefunSsoConfig } - | { ok: false, message: string } - -const CLOUDBASE_ENV_RE = /^[a-z0-9][a-z0-9-]{0,63}$/i -const TRAILING_SLASH_RE = /\/+$/ - -export function resolveYunlefunSsoConfig( - input: YunlefunSsoConfigInput, - context: YunlefunSsoConfigContext, -): YunlefunSsoConfigResult { - const clientId = readSsoClientId(input.clientId) - const cloudbaseEnv = normalizeValue(input.cloudbaseEnv) - const exchangeUrl = readSsoRedirectUri(input.exchangeUrl) - const webRedirectUris = readWebRedirectUris(input.redirectUris ?? input.redirectUri) - const redirectUri = webRedirectUris.find(uri => new URL(uri).origin === context.currentOrigin) - const scope = readSsoScope(input.scope) - const ssoOrigin = normalizeHttpsOrigin(input.ssoOrigin) - - if (!clientId - || !CLOUDBASE_ENV_RE.test(cloudbaseEnv) - || !exchangeUrl - || webRedirectUris.length === 0 - || scope.length === 0 - || !ssoOrigin) { - return { - ok: false, - message: '登录服务尚未完成 SSO v3 配置', - } - } - - if (!redirectUri) { - return { - ok: false, - message: '当前域名尚未登记为登录回跳地址', - } - } - - return { - ok: true, - config: { - cloudbaseEnv, - exchange: { exchangeUrl }, - redirect: { - clientId, - redirectUri, - scope, - ssoOrigin, - }, - }, - } -} - -function readWebRedirectUris(value: string | string[] | undefined): string[] { - const candidates = Array.isArray(value) - ? value - : typeof value === 'string' - ? value.split(/[\n,]/) - : [] - - return [...new Set(candidates - .map(candidate => readSsoRedirectUri(candidate.trim())) - .filter((candidate): candidate is string => Boolean(candidate)))] -} - -function normalizeHttpsOrigin(value: string | undefined): string { - const normalized = normalizeValue(value).replace(TRAILING_SLASH_RE, '') - if (!normalized) - return '' - - try { - const url = new URL(normalized) - return url.protocol === 'https:' - && !url.username - && !url.password - && url.origin === normalized - ? url.origin - : '' - } - catch { - return '' - } -} - -function normalizeValue(value: string | undefined): string { - return typeof value === 'string' ? value.trim() : '' -} diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 32645d5..9d73a6f 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -118,16 +118,8 @@ export default defineNuxtConfig({ public: { clarityProjectId: process.env.NUXT_PUBLIC_CLARITY_PROJECT_ID ?? 'ks1ebw03i5', clarityDomains: process.env.NUXT_PUBLIC_CLARITY_DOMAINS ?? 'cook.yunyoujun.cn,cook.yunle.fun', + // CloudBase 环境,用于「反馈一下」和匿名筛选信号;不涉及任何账号体系。 yunlefunCloudbaseEnv: process.env.NUXT_PUBLIC_YUNLEFUN_CLOUDBASE_ENV || 'yunlefun-8g7ybcxc7345c490', - yunlefunSsoClientId: process.env.NUXT_PUBLIC_YUNLEFUN_SSO_CLIENT_ID || 'cook-web', - yunlefunSsoExchangeUrl: process.env.NUXT_PUBLIC_YUNLEFUN_SSO_EXCHANGE_URL || 'https://api.yunle.fun/sso-ticket', - yunlefunSsoOrigin: process.env.NUXT_PUBLIC_YUNLEFUN_SSO_ORIGIN || 'https://www.yunle.fun', - yunlefunSsoRedirectUri: process.env.NUXT_PUBLIC_YUNLEFUN_SSO_REDIRECT_URI || 'https://cook.yunyoujun.cn/auth/callback', - yunlefunSsoRedirectUris: process.env.NUXT_PUBLIC_YUNLEFUN_SSO_REDIRECT_URIS || [ - process.env.NUXT_PUBLIC_YUNLEFUN_SSO_REDIRECT_URI || 'https://cook.yunyoujun.cn/auth/callback', - 'https://cook.yunle.fun/auth/callback', - ].join(','), - yunlefunSsoScope: process.env.NUXT_PUBLIC_YUNLEFUN_SSO_SCOPE || 'identity:bootstrap', }, }, @@ -159,7 +151,7 @@ export default defineNuxtConfig({ }, prerender: { crawlLinks: false, - routes: ['/', '/random', '/help', '/user', '/404', '/settings', '/auth/callback'], + routes: ['/', '/random', '/help', '/user', '/404', '/settings'], ignore: ['/hi'], }, }, diff --git a/apps/web/package.json b/apps/web/package.json index 9b0e78c..ba6381f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,6 @@ }, "dependencies": { "@cloudbase/js-sdk": "3.7.1", - "@yunlefun/sso": "0.7.0", "@yunyoujun/cook": "workspace:*", "dayjs": "^1.11.21", "vue-about-me": "^1.4.0" diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association deleted file mode 100644 index 8544bd7..0000000 --- a/apps/web/public/.well-known/apple-app-site-association +++ /dev/null @@ -1,13 +0,0 @@ -{ - "applinks": { - "apps": [], - "details": [ - { - "appID": "N3885VFNCL.cn.yunyoujun.cook", - "paths": [ - "/auth/callback" - ] - } - ] - } -} diff --git a/cloudbaserc.json b/cloudbaserc.json index ff114ee..d8649d2 100644 --- a/cloudbaserc.json +++ b/cloudbaserc.json @@ -4,16 +4,6 @@ "$schema": "https://framework-1258016615.tcloudbaseapp.com/schema/latest.json", "functionRoot": "./cloudfunctions", "functions": [ - { - "name": "cook-user-data", - "timeout": 10, - "runtime": "Nodejs18.15", - "memorySize": 256, - "installDependency": true, - "handler": "index.main", - "envVariables": {}, - "ignore": ["*.local", "node_modules"] - }, { "name": "cook-feedback", "timeout": 10, diff --git a/cloudfunctions/cook-user-data/index.js b/cloudfunctions/cook-user-data/index.js deleted file mode 100644 index acab02f..0000000 --- a/cloudfunctions/cook-user-data/index.js +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Cook favorites / history sync (YunYouJun/cook#98). - * - * The `cook_user_data` collection is ADMINONLY: browsers never read or write - * it directly. This function takes the caller from the verified CloudBase - * session, never from the request, and always uses it as the document id, so - * nobody can read, overwrite or pre-create another account's document. - */ - -const { Buffer } = require('node:buffer') - -const COLLECTION = 'cook_user_data' -const USER_DATA_VERSION = 1 -const MAX_DOCUMENT_BYTES = 256 * 1024 -const LIST_LIMITS = { - favorites: 5000, - favoriteRemovals: 1000, - histories: 500, - historyRemovals: 1000, -} -const ANON_UIDS = new Set(['', 'anon']) - -let app - -function getApp() { - if (!app) { - // Loaded lazily so tests can inject a fake app without the SDK installed. - const tcb = require('@cloudbase/node-sdk') - app = tcb.init({ env: tcb.SYMBOL_CURRENT_ENV }) - } - return app -} - -/** The signed-in, non-anonymous caller, or '' when there is none. */ -function callerUid() { - try { - const info = getApp().auth().getUserInfo() - const uid = info && info.uid ? String(info.uid) : '' - return ANON_UIDS.has(uid) || (info && info.isAnonymous === true) ? '' : uid - } - catch { - return '' - } -} - -function fail(code) { - return { ok: false, code } -} - -function isRecord(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -/** - * Check the shape and size of an uploaded document. Entry contents are - * normalised again by every client that reads them. - */ -function readDocument(value) { - if (!isRecord(value) || value.version !== USER_DATA_VERSION) - return null - if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_DOCUMENT_BYTES) - return null - - const document = { version: USER_DATA_VERSION } - for (const [key, limit] of Object.entries(LIST_LIMITS)) { - const list = value[key] ?? [] - if (!Array.isArray(list) || list.length > limit || !list.every(isRecord)) - return null - document[key] = list - } - const clearedAt = value.historyClearedAt ?? 0 - if (typeof clearedAt !== 'number' || !Number.isFinite(clearedAt) || clearedAt < 0) - return null - document.historyClearedAt = clearedAt - return document -} - -function stripMeta(doc) { - if (!isRecord(doc)) - return null - const { _id, _openid, ...rest } = doc - return rest -} - -async function getDocument(uid) { - const result = await getApp().database().collection(COLLECTION).doc(uid).get() - const doc = result && Array.isArray(result.data) ? result.data[0] : null - return { ok: true, document: stripMeta(doc) } -} - -async function setDocument(uid, input, now) { - const document = readDocument(input) - if (!document) - return fail('INVALID_DOCUMENT') - await getApp().database().collection(COLLECTION).doc(uid).set({ ...document, updatedAt: now }) - return { ok: true, updatedAt: now } -} - -async function main(event = {}) { - const uid = callerUid() - if (!uid) - return fail('LOGIN_REQUIRED') - - try { - switch (event.action) { - case 'get': - return await getDocument(uid) - case 'set': - return await setDocument(uid, event.document, Date.now()) - default: - return fail('UNKNOWN_ACTION') - } - } - catch (error) { - console.error('[cook-user-data]', event.action, error && error.message) - return fail('INTERNAL_ERROR') - } -} - -exports.main = main -exports._internal = { - COLLECTION, - LIST_LIMITS, - MAX_DOCUMENT_BYTES, - readDocument, - setAppForTest(nextApp) { - app = nextApp - }, -} diff --git a/cloudfunctions/cook-user-data/package.json b/cloudfunctions/cook-user-data/package.json deleted file mode 100644 index 87b6a9d..0000000 --- a/cloudfunctions/cook-user-data/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "cook-user-data", - "version": "0.0.0", - "private": true, - "main": "index.js", - "dependencies": { - "@cloudbase/node-sdk": "^3.18.1" - } -} diff --git a/docs/.vitepress/config/index.ts b/docs/.vitepress/config/index.ts index 03e1a27..13fdd13 100644 --- a/docs/.vitepress/config/index.ts +++ b/docs/.vitepress/config/index.ts @@ -48,6 +48,7 @@ export default defineConfig({ { text: '0002 反馈与固定 id', link: '/decisions/0002-feedback-and-ids' }, { text: '0003 原生 iOS 与 monorepo', link: '/decisions/0003-native-ios-monorepo' }, { text: '0004 只保留 Web', link: '/decisions/0004-web-only' }, + { text: '0005 去掉登录与云同步', link: '/decisions/0005-drop-login-and-sync' }, ], }, ], diff --git a/docs/decisions/0004-web-only.md b/docs/decisions/0004-web-only.md index 7c246d6..d537141 100644 --- a/docs/decisions/0004-web-only.md +++ b/docs/decisions/0004-web-only.md @@ -1,8 +1,8 @@ # 0004 只保留 Web,移除 Capacitor 原生壳 -- 状态:已采纳 +- 状态:部分被 0005 取代 - 日期:2026-10-21 -- 关联:取代 [0003 原生 iOS App 与 monorepo 结构](./0003-native-ios-monorepo) +- 关联:取代 [0003 原生 iOS App 与 monorepo 结构](./0003-native-ios-monorepo);云乐坊 SSO 部分被 [0005 个人自用,去掉登录与云同步](./0005-drop-login-and-sync) 取代 ## 背景 diff --git a/docs/decisions/0005-drop-login-and-sync.md b/docs/decisions/0005-drop-login-and-sync.md new file mode 100644 index 0000000..59cfc9f --- /dev/null +++ b/docs/decisions/0005-drop-login-and-sync.md @@ -0,0 +1,70 @@ +# 0005 个人自用,去掉登录与云同步 + +- 状态:已采纳 +- 日期:2026-10-22 +- 关联:[0002 反馈与固定 id](./0002-feedback-and-ids)(保留反馈链路)、[0004 只保留 Web](./0004-web-only) + +## 背景 + +- 这份部署只给本人用,没有第二个用户,也不需要在多台设备之间来回。 +- 但登录链路一直没有停用:云乐坊 SSO 授权(`@yunlefun/sso` 0.7.0)、PKCE 顶层重定向回 + `/auth/callback`、CloudBase 会话持久化、收藏与浏览历史的云端双向同步、获客归因埋点, + 以及为支持多账号切换而写的 `lastUid` 记账。 +- 这套东西的成本不只是代码量: + - 每次登录状态检查失败都会在「我的」页显示错误提示,而它对个人自用毫无意义; + - 云同步失败时用户看到的是「云端同步失败,点击重试」,同样无法被单个人解决; + - `mergeUserData` 的账号切换分支、`UserDataSyncScheduler` 的退避重试、 + `normalizeUserData` 对云端文档的信任边界,全部只为「多设备 + 多账号」存在。 +- 界面上它也占了位置:「我的」页首屏是登录按钮、账号卡和四个统计格,其中「上次同步」 + 永远是空的或看不懂;「关于」页大半篇幅是项目作者、致谢名单和上游宣传链接。 + +## 方案对比 + +| 问题 | 方案 | 取舍 | +| ------------ | ----------------------------------- | -------------------------------------------------------- | +| 登录功能去留 | 保留登录,仅隐藏界面入口 | 入口消失,但 6 个云函数依赖、回调页和死代码仍在,误导性维护成本最高 | +| | 保留登录 + 保留云同步 | 对单用户没有任何收益;同步失败路径无法自愈 | +| | **完全移除登录与云同步** | 收藏/历史跨设备要手动导出导入;换来链路消失、界面清爽、无失败态 | + +## 决定 + +### 删除 + +- 登录链路:`composables/useYunlefunAuth.ts`、`utils/yunlefunAuth.ts`、`utils/yunlefunSsoConfig.ts`、 + `adapters/yunlefunAuth.ts`、`plugins/yunlefun-auth.client.ts`、`pages/auth/callback.vue`、 + `public/.well-known/apple-app-site-association`(唯一 path 就是回调页)。 +- 云同步链路:`composables/useUserDataSync.ts`、`utils/userDataSync.ts`、`adapters/userData.ts`、 + `plugins/user-data-sync.client.ts`、`cloudfunctions/cook-user-data/`、 + `scripts/cloudbase-rules/cook_user_data.json`、`cloudbaserc.json` 里对应的函数条目。 +- 获客归因:`utils/acquisition.ts`、`plugins/acquisition.client.ts`。它只在 SSO 重定向上报, + 登录没有了就没有消费者。 +- 依赖:根与 `apps/web` 的 `@yunlefun/sso`,以及 `pnpm-workspace.yaml` 里为它开的 + `minimumReleaseAgeExclude` 例外。`@cloudbase/js-sdk` 保留(反馈仍需要)。 +- 界面:「我的」页的登录按钮、账号卡、四个统计格、退出登录;「关于」页的项目作者、致谢名单、 + 网页版本、旅程的起点、前世今生。随之孤立的 `pages/about/me.vue`、 + `pages/about/acknowledgements.vue`、`components/TheAboutList.vue`、 + `constants/acknowledgements.ts` 一并删除。 +- 文案:设置页和帮助页里「登录后会自动同步」的说明改为「换设备可导出再导入」。 + +### 保留 + +- **反馈链路**:`cook-feedback` 云函数与「反馈一下」、匿名筛选信号。它本来就不强制登录, + 未登录时用随机 device id 去重限流。`callCloudbaseFunction` 从 `adapters/yunlefunAuth.ts` + 抽到新的 `adapters/cloudbase.ts`,`useRecipeFeedback` 直接调用,CloudBase 环境变量 + `yunlefunCloudbaseEnv` 保留。 +- **本地数据**:收藏、历史、自己记的菜谱照旧存在浏览器 localStorage。设置页的导出/导入 + (`exportLocalUserData` / `importLocalUserData`)是现在唯一的跨设备手段,删除墓碑 + (`favoriteRemovals` / `historyRemovals` / `historyClearedAt`)继续生效,避免导入时复活已删除的记录。 +- `utils/analytics.ts` 的 `isPrivateAnalyticsUrl`:它遮蔽 `/auth` 路径和 + `ticket` / `code` / `access_token` 等凭证参数,是通用的隐私防护,不随登录下线而删除。 +- `utils/yunleHost.ts` 与云乐坊容器(`window.ylf`)集成:这是 Web 被嵌入第三方 App 的适配层, + 与登录无关。 + +## 影响 + +- 收藏和历史不会自动跨设备。换设备或清浏览器数据前,需要在设置页导出 JSON 再导入。 +- 「我的」页不再有账号卡和同步状态,因此没有「未登录」「同步失败」这类对个人无意义的提示。 +- 云端不再有 `cook_user_data` 集合和它的读写流量。已部署的旧函数和集合需要手动在 CloudBase + 控制台删除,代码里不再维护。 +- 想要回来时:恢复 `@yunlefun/sso` 依赖和 `/auth/callback` 页面,把 `syncUserData` 与 + `UserDataLocal` 的 `readLastUid` / `writeLastUid` 接回 `store/userData.ts` 即可。 \ No newline at end of file diff --git a/docs/decisions/index.md b/docs/decisions/index.md index d934778..028c44d 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -15,4 +15,5 @@ | [0001](./0001-recipe-data) | 菜谱数据的来源、质量与反馈闭环 | 提议中 | | [0002](./0002-feedback-and-ids) | 菜谱固定 id、应用内反馈与筛选信号 | 已采纳 | | [0003](./0003-native-ios-monorepo) | 原生 iOS App 与 monorepo 结构 | 被 0004 取代 | -| [0004](./0004-web-only) | 只保留 Web,移除 Capacitor 原生壳 | 已采纳 | +| [0004](./0004-web-only) | 只保留 Web,移除 Capacitor 原生壳 | 部分被 0005 取代 | +| [0005](./0005-drop-login-and-sync) | 个人自用,去掉登录与云同步 | 已采纳 | diff --git a/docs/dev/acquisition.md b/docs/dev/acquisition.md deleted file mode 100644 index 2ea87dc..0000000 --- a/docs/dev/acquisition.md +++ /dev/null @@ -1,34 +0,0 @@ -# 注册来源记录与发布 - -Cook 在文档初次进入时记录来源。`firstTouch` 保存在当前域名的 localStorage,30 天后重新采集;`registerTouch` 保存在当前标签的 sessionStorage,每次文档进入更新。SPA 内跳页不会把原落地渠道改成站内页面。`/auth/*` 回调不采集,以免把统一登录、GitHub 或微信授权页计入获客来源。存储被禁用时,本次文档以内存降级,来源失败不会阻止登录。 - -可读存储被清空或损坏时,以当前存储状态为准,不能从旧内存恢复已删除的来源。访问存储被拒绝时使用经过校验的内存上下文;若已知新写入失败且存储仍保留完全相同的旧值,使用当前文档的新来源,防止旧活动回流。可观察的删除或值变化会清除此降级状态。首次与本次入口超过 30 天均失效,不长期保留旧活动。 - -来源只携带落地域名、路径、外部 referrer 域名以及 `utm_source`、`utm_medium`、`utm_campaign`。`yunle.fun`、其全部子域、`cook.yunyoujun.cn` 和当前域名的站内跳转不会计入外部 referrer,避免 SSO 或 Apps 往返被统计成获客渠道。查询、hash 和凭证不会写入来源记录。没有 referrer/UTM 表示来源缺失,不能据此断言为直接访问。 - -登录前读取同一份 `{ version: 1, firstTouch, registerTouch }`,同时覆盖 Web 顶层重定向和 Apps host bridge。来源为客户端上报数据;统一登录服务依据 Registry 验证应用入口并依据 Auth 判断账号是否真正新注册,不能按 Cook 首次登录或公开资料创建时间判断。后续登录不会覆盖账号已保存的注册来源。 - -## 本地联调 - -新协议在 `@yunlefun/sso@0.7.0`,正式依赖使用 npm registry 的锁定版本。需要联调尚未发布的改动时,可用本地 `YunLeFun/ylf` checkout 的实际构建/打包产物验证: - -```sh -node scripts/qa/verify-acquisition-sdk.mjs --sdk-root=/path/to/ylf -# 可选:同时验证当前应用的构建与类型(包括已有工作区改动) -node scripts/qa/verify-acquisition-sdk.mjs --sdk-root=/path/to/ylf --build --typecheck -``` - -脚本构建 SDK 并生成临时 tarball,短暂切换已有 node_modules 的 SSO 软链接,运行定向测试和 lint,最后恢复旧软链接。它不修改 package.json、lockfile 或 pnpm override,也不发布 npm。运行时避免并发安装依赖。 - -## 发布顺序 - -1. 先发布账户私有来源集合/写入 API 与统一登录、SSO 服务端(它们可先接收可选字段并继续兼容旧客户端)。 -2. 从 `YunLeFun/ylf` 发布已验证的 `@yunlefun/sso@0.7.0`;该版本给现有 PKCE 协议增加可选来源字段。 -3. 在 Cook 解析并更新 0.7.0 的 lockfile,复核保留当前其他依赖改动,再执行 frozen install、typecheck 和构建并发布。 -4. 发布 Apps 的来源透传代码。 - -`@yunlefun/sso@0.7.0` 已由公开的 `YunLeFun/ylf` 仓库通过 OIDC 发布,附带 GitHub Actions provenance。Cook 的 lockfile 使用实际 registry integrity,安装与发布不依赖临时本地路径或 override。`minimumReleaseAgeExclude` 只为已核验的自有版本 `@yunlefun/sso@0.7.0` 提供精确例外,其余依赖继续遵循 24 小时等待和供应链策略。 - -Cook 主域名 `cook.yunyoujun.cn` 对应 EdgeOne Upload 项目 `yunyoujun-cook`,发布时使用该项目名;`cook.yunle.fun` 是另一个 Git 集成项目。云乐坊容器内的发布仍按各自流程验收。 - -历史用户最多保留来自 SSO 时间关联的应用入口推断;不能把 GA 总体流量比例回填为某个 UID 的外部渠道。各域名、不同浏览器和被清除的存储也无法恢复此前未采集的触达。 diff --git a/docs/dev/analytics.md b/docs/dev/analytics.md index 17ec02c..c88c59f 100644 --- a/docs/dev/analytics.md +++ b/docs/dev/analytics.md @@ -49,6 +49,8 @@ NUXT_PUBLIC_CLARITY_DOMAINS=cook.yunyoujun.cn,cook.yunle.fun 直接进入 `/auth` 或携带常见凭证参数的页面时不加载脚本。已加载的页面导航到这类 URL 前调用 Clarity `stop`,并在本次文档生命周期内停止业务事件;重新加载普通页面后恢复。脚本加载或事件发送失败不会阻断 Cook 操作。 +站点已无登录流程,`/auth` 分支保留为通用兜底:一旦将来引入带授权码的回调页,它不会被回放采集。 + 代码不调用 `identify`,也不会替访客发送“已同意”的信号。有同意管理需求的部署应连接真实的用户选择;Clarity 对 EEA、英国、瑞士的访问有同意信号要求,未提供时部分功能受限。站点隐私说明应包含实际使用的统计服务。 ## 验收 @@ -56,7 +58,7 @@ NUXT_PUBLIC_CLARITY_DOMAINS=cook.yunyoujun.cn,cook.yunle.fun 1. 部署后在允许的生产域名访问首页,确认 `https://www.clarity.ms/tag/ks1ebw03i5` 只加载一次。 2. 检查 Clarity 采集请求成功及负载中的固定事件名,再在后台录制时间线确认新漏斗事件;仪表板汇总可能延迟,HTTP 204 不代表汇总已经展示。 3. 切换页面、选择食材、打开菜谱,验证功能正常;阻断 Clarity 域名后重复验证。 -4. 查看一条回放,确认账号和自建内容被遮罩;登录回调入口不应加载脚本。 +4. 查看一条回放,确认自建内容被遮罩;带凭证参数的 URL 不应加载脚本。 5. 中国大陆访问效果需要用目标运营商网络实测,不能用本机可访问代替全部用户的可达性结论。 Clarity 官方当前提供免费服务。普通回放保留 30 天,标记或收藏的会话及热力图可保留 9 个月;单项目每日最多保存 10 万条会话回放,超过后可能抽样。它适合行为分析,不替代长期原始事件仓库。 diff --git a/docs/dev/app.md b/docs/dev/app.md index 86bac6a..76f9823 100644 --- a/docs/dev/app.md +++ b/docs/dev/app.md @@ -1,6 +1,6 @@ # Web 应用 -Cook 只保留 Web 一套代码,服务普通浏览器和云乐坊容器。技术栈: +Cook 只保留 Web 一套代码。技术栈: - [Nuxt 4](https://nuxt.com/)(Vue 3,CSR-only,静态生成) - [Ionic](https://ionicframework.com/) + [Nuxt Ionic](https://ionic.nuxtjs.org/)(Web UI 框架,与 Capacitor 无关) @@ -8,17 +8,13 @@ Cook 只保留 Web 一套代码,服务普通浏览器和云乐坊容器。技 原生 App(iOS / Android)的代码已随 [0004 只保留 Web,移除 Capacitor 原生壳](/decisions/0004-web-only) 一并移除。 -## 云乐坊 SSO +## 没有账号体系 -Web 端有两种登录路径: +站点没有登录,也没有云端同步。收藏、浏览历史和自己记的菜谱都存在浏览器 localStorage, +换设备或清浏览器数据前需要在设置页导出 JSON 再导入,详见 +[0005 个人自用,去掉登录与云同步](/decisions/0005-drop-login-and-sync)。 -- **在云乐坊 App 容器内**:通过宿主注入的 `window.ylf` JSAPI 直接请求宿主授权面板,用户不离开当前页面。 -- **在普通浏览器中**:走顶层重定向到云乐坊授权页,授权完成后回到 - `https://cook.yunyoujun.cn/auth/callback`,由该页面换回登录票据。 +## 云乐坊容器 -回跳地址必须是已在云乐坊登记的 HTTPS 地址,且不能带 `?platform=native` 之类的原生标记。 -PKCE 能阻止授权码被旁路窃取,但不能阻止恶意客户端主动冒充公开客户端发起授权, -因此不要放宽回跳地址的登记要求。 - -回跳域名有多个时(`cook.yunyoujun.cn`、`cook.yunle.fun`),按当前页面 origin 选对应的那个, -`yunlefunSsoRedirectUris` 里没登记的域名会直接判定为配置错误并拒绝登录。 +被云乐坊 App(`window.ylf` JSAPI)内嵌时会按宿主的展示形态、安全区与胶囊位置调整布局, +实现见 `utils/yunleHost.ts`。这是 Web 被第三方 App 容器加载时的适配层,与账号无关。 \ No newline at end of file diff --git a/docs/dev/data.md b/docs/dev/data.md index c1335c7..6a9b2fa 100644 --- a/docs/dev/data.md +++ b/docs/dev/data.md @@ -93,7 +93,7 @@ pnpm eval:search # 用真实信号评估当前排序:前 10 命中率、MR 1. 在 CloudBase 控制台新建三个集合,权限都设为「仅管理端可读写」:`cook_recipe_feedback`、`cook_search_signals`、`cook_feedback_quota`。 2. 部署云函数:`tcb fn deploy cook-feedback`(配置见 `cloudbaserc.json`)。 3. 给 `cook-feedback` 设置环境变量 `COOK_FEEDBACK_EXPORT_TOKEN`(一串足够长的随机字符),并开启 HTTP 访问服务,得到一个访问地址。 -4. 让未登录用户也能调用 `cook-feedback`:在控制台开启匿名登录或允许未登录访问该函数。否则只有登录用户能提交,未登录用户会被引导去 GitHub。 +4. 让未登录用户也能调用 `cook-feedback`:在控制台开启匿名登录或允许未登录访问该函数。站点本身没有登录功能,前端也不携带任何账号信息,所以这一项是必需的。 5. 本机 `.env` 填 `COOK_FEEDBACK_URL`(第 3 步的地址)和 `COOK_FEEDBACK_TOKEN`(同一个口令),之后就能 `pnpm feedback:pull`。 云函数对每个设备(或账号)每天最多接受 200 次提交;同一个人对同一道菜的同一类问题每天只记一次。 diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 0af11a9..2e02844 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -76,5 +76,5 @@ cook/ ## 下一步 - 📖 查看 [CLI 文档](/dev/cli) 了解数据管理 -- 💻 查看 [Web 应用开发](/dev/app) 了解技术栈与登录流程 +- 💻 查看 [Web 应用开发](/dev/app) 了解技术栈与数据存放方式 - 🍳 开始添加你喜欢的菜谱! diff --git a/package.json b/package.json index a943c3e..504ba10 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "@unocss/eslint-config": "^66.7.5", "@vue/test-utils": "^2.4.11", "@vueuse/core": "^14.4.0", - "@yunlefun/sso": "0.7.0", "baseline-browser-mapping": "^2.11.13", "bumpp": "^11.1.0", "consola": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d371d..6e7b864 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,6 @@ importers: '@vueuse/core': specifier: ^14.4.0 version: 14.4.0(vue@3.5.41(typescript@5.9.3)) - '@yunlefun/sso': - specifier: 0.7.0 - version: 0.7.0 baseline-browser-mapping: specifier: ^2.11.13 version: 2.11.13 @@ -81,9 +78,6 @@ importers: '@cloudbase/js-sdk': specifier: 3.7.1 version: 3.7.1 - '@yunlefun/sso': - specifier: 0.7.0 - version: 0.7.0 '@yunyoujun/cook': specifier: workspace:* version: link:../../packages/cook @@ -3540,9 +3534,6 @@ packages: engines: {node: '>=14.6'} deprecated: this version has critical issues, please update to the latest version - '@yunlefun/sso@0.7.0': - resolution: {integrity: sha512-8Omjc/4ASVJLJDiPiUtP11PI6fygTnUdLPtuv7txNzV6jtbjBnorF/eFJ7cB4ivh2m2O3GbLVqX/q1teK9OH+Q==} - '@yunlefun/ui@0.0.6': resolution: {integrity: sha512-XWvfNpKbTlF+0FA+dJg9koGbdx8/0QuXXQetB9GmCBT9kKZsreBMf89m5WpzML4DHxStBRxyb3GOBegl6TNidQ==} peerDependencies: @@ -5618,9 +5609,6 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.8: - resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} - js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -12537,10 +12525,6 @@ snapshots: '@xmldom/xmldom@0.9.10': {} - '@yunlefun/sso@0.7.0': - dependencies: - jose: 6.2.8 - '@yunlefun/ui@0.0.6(sass@1.102.0)': optionalDependencies: sass: 1.102.0 @@ -14831,8 +14815,6 @@ snapshots: jiti@2.7.0: {} - jose@6.2.8: {} - js-beautify@1.15.4: dependencies: config-chain: 1.1.13 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 009c990..4e64e8a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,9 +3,6 @@ autoInstallPeers: false ignoreWorkspaceRootCheck: true strictDepBuilds: true minimumReleaseAge: 1440 -minimumReleaseAgeExclude: - # First-party release reviewed and matched to the published tarball before rollout. - - '@yunlefun/sso@0.7.0' shamefullyHoist: true shellEmulator: true blockExoticSubdeps: true diff --git a/scripts/cloudbase-rules/cook_user_data.json b/scripts/cloudbase-rules/cook_user_data.json deleted file mode 100644 index 133eed0..0000000 --- a/scripts/cloudbase-rules/cook_user_data.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "action": "updateResourcePermission", - "resourceType": "noSqlDatabase", - "resourceId": "cook_user_data", - "permission": "ADMINONLY" -} diff --git a/scripts/qa/verify-acquisition-sdk.mjs b/scripts/qa/verify-acquisition-sdk.mjs deleted file mode 100644 index f594175..0000000 --- a/scripts/qa/verify-acquisition-sdk.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' -import { lstatSync, mkdtempSync, readdirSync, renameSync, rmSync, symlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -const argument = process.argv.find(value => value.startsWith('--sdk-root=')) -assert(argument, 'Pass --sdk-root=/path/to/ylf (a local checkout containing packages/sso)') -const sdkRoot = resolve(argument.slice('--sdk-root='.length)) -const cookRoot = fileURLToPath(new URL('../../', import.meta.url)) -const dependency = join(cookRoot, 'node_modules/@yunlefun/sso') -assert(lstatSync(dependency).isSymbolicLink(), 'Expected the existing pnpm SSO dependency symlink') -const temporary = mkdtempSync(join(tmpdir(), 'cook-acquisition-sdk-')) -const previous = join(temporary, 'previous-sso-link') -let replaced = false -const run = (args, cwd) => execFileSync('pnpm', args, { cwd, stdio: 'inherit' }) -// Invoke installed tools directly: newer pnpm can auto-install on `exec` when the release lock is pending. -function runCookTool(tool, args) { - return execFileSync(join(cookRoot, 'node_modules/.bin', tool), args, { - cwd: cookRoot, - stdio: 'inherit', - }) -} - -try { - run(['--filter', '@yunlefun/sso', 'build'], sdkRoot) - run(['pack', '--pack-destination', temporary], join(sdkRoot, 'packages/sso')) - const archive = readdirSync(temporary).find(value => value.endsWith('.tgz')) - assert(archive, 'SDK pack must produce a tarball') - execFileSync('tar', ['-xzf', join(temporary, archive), '-C', temporary]) - symlinkSync(join(sdkRoot, 'packages/sso/node_modules'), join(temporary, 'package/node_modules'), 'dir') - renameSync(dependency, previous) - replaced = true - symlinkSync(join(temporary, 'package'), dependency, 'dir') - runCookTool('vitest', ['run', 'test/acquisition.test.ts', 'test/yunlefunAuthAdapter.test.ts', 'test/yunlefunAuth.test.ts', 'test/yunlefunSsoConfig.test.ts']) - runCookTool('eslint', ['apps/web/app/utils/acquisition.ts', 'apps/web/app/plugins/acquisition.client.ts', 'apps/web/app/plugins/yunlefun-auth.client.ts', 'apps/web/app/adapters/yunlefunAuth.ts', 'test/acquisition.test.ts', 'test/yunlefunAuthAdapter.test.ts', 'test/yunlefunAuth.test.ts', 'scripts/qa/verify-acquisition-sdk.mjs']) - if (process.argv.includes('--build')) - runCookTool('nuxt', ['build']) - if (process.argv.includes('--typecheck')) - runCookTool('nuxt', ['typecheck']) -} -finally { - if (replaced) { - rmSync(dependency, { force: true }) - renameSync(previous, dependency) - } - rmSync(temporary, { recursive: true, force: true }) -} diff --git a/test/acquisition.test.ts b/test/acquisition.test.ts deleted file mode 100644 index fcd0e97..0000000 --- a/test/acquisition.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -function browser(url = 'https://cook.yunle.fun/?utm_source=知乎&utm_medium=social&utm_campaign=秋季菜谱', referrer = 'https://www.baidu.com/search?q=private') { - const local = new Map() - const session = new Map() - const storage = (values: Map) => ({ - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => values.set(key, value), - }) - return { - document: { referrer }, - localStorage: storage(local), - location: new URL(url), - sessionStorage: storage(session), - } as unknown as Pick -} - -beforeEach(() => vi.resetModules()) - -describe('cook document acquisition', () => { - it.each([ - 'https://yunle.fun/', - 'https://www.yunle.fun/login', - 'https://apps.yunle.fun/', - 'https://nested.apps.yunle.fun/', - 'https://cook.yunyoujun.cn/', - 'https://yunle.fun./login', - 'https://cook.yunle.fun/random', - ])('excludes internal ecosystem navigation from external acquisition: %s', async (referrer) => { - const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const context = captureCookAcquisition(browser('https://cook.yunle.fun/', referrer), 1000)! - expect(context.firstTouch.referrerHost).toBe('') - expect(context.registerTouch.referrerHost).toBe('') - }) - - it.each(['https://github.com/YunYouJun/cook', 'https://www.zhihu.com/', 'https://notyunle.fun/'])( - 'retains an actual external referrer: %s', - async (referrer) => { - const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const context = captureCookAcquisition(browser('https://cook.yunle.fun/', referrer), 1000)! - expect(context.registerTouch.referrerHost).toBe(new URL(referrer).hostname) - }, - ) - - it('keeps first touch for 30 days while updating the current document entry', async () => { - const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const entry = browser() - const first = captureCookAcquisition(entry, 1000)! - entry.location = new URL('https://cook.yunle.fun/random?utm_source=github') as unknown as Location - const second = captureCookAcquisition(entry, 2000)! - expect(second.firstTouch).toEqual(first.firstTouch) - expect(second.registerTouch.landingPath).toBe('/random') - expect(second.registerTouch.utmSource).toBe('github') - expect(second.registerTouch.referrerHost).toBe('www.baidu.com') - expect(JSON.stringify(second)).not.toContain('q=private') - - const expired = captureCookAcquisition(entry, 1000 + 30 * 86400000)! - expect(expired.firstTouch).toEqual(expired.registerTouch) - expect(expired.firstTouch.utmSource).toBe('github') - }) - - it('preserves the pre-auth entry through a callback and never captures OAuth referrers', async () => { - const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const entry = browser() - const context = captureCookAcquisition(entry, 1000) - entry.location = new URL('https://cook.yunle.fun/auth/callback?code=private#ylf_sso=private') as unknown as Location - Object.assign(entry.document, { referrer: 'https://github.com/login/oauth' }) - expect(captureCookAcquisition(entry, 2000)).toEqual(context) - expect(readCookAcquisition(entry, 2000)).toEqual(context) - expect(JSON.stringify(context)).not.toContain('private') - }) - - it('does not invent attribution for a callback without an earlier document entry', async () => { - const { captureCookAcquisition } = await import('../apps/web/app/utils/acquisition') - expect(captureCookAcquisition(browser('https://cook.yunle.fun/auth/callback'), 1000)).toBeNull() - }) - - it.each([null, '{broken', JSON.stringify({ capturedAt: -1 })])('does not restore a readable deleted or invalid entry from memory: %s', async (stored) => { - const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const entry = browser() - expect(captureCookAcquisition(entry, 1000)?.registerTouch.utmSource).toBe('知乎') - entry.sessionStorage.getItem = () => stored - expect(readCookAcquisition(entry, 2000)).toBeNull() - entry.sessionStorage.getItem = () => { - throw new Error('storage blocked') - } - expect(readCookAcquisition(entry, 3000)).toBeNull() - }) - - it('uses the current entry when readable first touch is cleared, without reviving its old campaign', async () => { - const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const entry = browser() - captureCookAcquisition(entry, 1000) - entry.location = new URL('https://cook.yunle.fun/random?utm_source=github') as unknown as Location - captureCookAcquisition(entry, 2000) - entry.localStorage.getItem = () => null - expect(readCookAcquisition(entry, 3000)?.firstTouch.utmSource).toBe('github') - entry.localStorage.getItem = () => { - throw new Error('storage blocked') - } - expect(readCookAcquisition(entry, 4000)?.firstTouch.utmSource).toBe('github') - }) - - it('keeps a validated memory first touch across blocked storage and expires stale entry data', async () => { - const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const entry = browser() - for (const storage of [entry.localStorage, entry.sessionStorage]) { - storage.getItem = () => { - throw new Error('storage blocked') - } - storage.setItem = () => { - throw new Error('storage blocked') - } - } - captureCookAcquisition(entry, 1000) - entry.location = new URL('https://cook.yunle.fun/random?utm_source=github') as unknown as Location - const current = captureCookAcquisition(entry, 2000)! - expect(current.firstTouch.utmSource).toBe('知乎') - expect(current.registerTouch.utmSource).toBe('github') - expect(readCookAcquisition(entry, 2000 + 30 * 86400000)).toBeNull() - }) - - it('uses the new entry when a failed write leaves the old value readable, then respects deletion', async () => { - const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const entry = browser('https://cook.yunle.fun/?utm_source=old') - captureCookAcquisition(entry, 1000) - entry.sessionStorage.setItem = () => { - throw new Error('quota exceeded') - } - entry.location = new URL('https://cook.yunle.fun/random?utm_source=new') as unknown as Location - expect(captureCookAcquisition(entry, 2000)?.registerTouch.utmSource).toBe('new') - expect(readCookAcquisition(entry, 3000)?.registerTouch.utmSource).toBe('new') - expect(readCookAcquisition(entry, 3000)?.firstTouch.utmSource).toBe('old') - entry.sessionStorage.getItem = () => null - expect(readCookAcquisition(entry, 4000)).toBeNull() - entry.sessionStorage.getItem = () => { - throw new Error('storage blocked') - } - expect(readCookAcquisition(entry, 5000)).toBeNull() - }) - - it('ignores credentials, invalid UTM and storage failures without blocking authentication', async () => { - const { captureCookAcquisition, readCookAcquisition } = await import('../apps/web/app/utils/acquisition') - const entry = browser('https://cook.yunle.fun/?utm_source=hello%0Aworld&token=private', 'https://user:pass@example.com/path') - for (const storage of ['localStorage', 'sessionStorage']) { - Object.defineProperty(entry, storage, { - get() { - throw new Error('storage blocked') - }, - }) - } - const context = captureCookAcquisition(entry, 1000)! - expect(context.registerTouch.referrerHost).toBe('') - expect(context.registerTouch.utmSource).toBe('') - expect(readCookAcquisition(entry, 2000)).toEqual(context) - expect(JSON.stringify(context)).not.toContain('private') - }) -}) diff --git a/test/cookUserDataFunction.test.ts b/test/cookUserDataFunction.test.ts deleted file mode 100644 index 034ac0d..0000000 --- a/test/cookUserDataFunction.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { createRequire } from 'node:module' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -interface FunctionModule { - main: (event?: Record) => Promise> - _internal: { - COLLECTION: string - LIST_LIMITS: Record - MAX_DOCUMENT_BYTES: number - readDocument: (value: unknown) => Record | null - setAppForTest: (app: unknown) => void - } -} - -const require = createRequire(import.meta.url) -const fn = require('../cloudfunctions/cook-user-data/index.js') as FunctionModule - -function createApp(userInfo: Record | (() => never)) { - const docs = new Map>() - const doc = vi.fn((id: string) => ({ - get: async () => ({ data: docs.has(id) ? [{ _id: id, _openid: 'x', ...docs.get(id) }] : [] }), - set: async (data: Record) => { - docs.set(id, data) - return { updated: 1 } - }, - })) - const collection = vi.fn(() => ({ doc })) - const app = { - auth: () => ({ getUserInfo: typeof userInfo === 'function' ? userInfo : () => userInfo }), - database: () => ({ collection }), - } - fn._internal.setAppForTest(app) - return { collection, doc, docs } -} - -const document = { - version: 1, - favorites: [{ id: 1, time: 1 }], - favoriteRemovals: [], - histories: [{ recipe: { name: 'A', stuff: [], tools: [] }, time: 2 }], - historyRemovals: [], - historyClearedAt: 0, -} - -describe('cook-user-data function', () => { - beforeEach(() => { - vi.useRealTimers() - }) - - it('rejects callers without a real account', async () => { - for (const info of [{}, { uid: '' }, { uid: 'anon' }, { uid: 'u1', isAnonymous: true }]) { - const { doc } = createApp(info) - await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: false, code: 'LOGIN_REQUIRED' }) - expect(doc).not.toHaveBeenCalled() - } - createApp(() => { - throw new Error('no context') - }) - await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: false, code: 'LOGIN_REQUIRED' }) - }) - - it('always uses the caller uid as the document id', async () => { - const { collection, doc, docs } = createApp({ uid: 'u1' }) - - const result = await fn.main({ action: 'set', document, uid: 'victim', _id: 'victim' }) - - expect(result).toMatchObject({ ok: true }) - expect(collection).toHaveBeenCalledWith('cook_user_data') - expect(doc.mock.calls.map(call => call[0])).toEqual(['u1']) - expect(docs.has('victim')).toBe(false) - expect(docs.get('u1')).toMatchObject({ version: 1, favorites: [{ id: 1, time: 1 }] }) - }) - - it('returns the caller document without CloudBase metadata', async () => { - createApp({ uid: 'u1' }) - await fn.main({ action: 'set', document }) - - const result = await fn.main({ action: 'get' }) - - expect(result.ok).toBe(true) - expect(result.document).toMatchObject({ version: 1, favorites: [{ id: 1, time: 1 }] }) - expect(result.document).not.toHaveProperty('_id') - expect(result.document).not.toHaveProperty('_openid') - }) - - it('returns null for an account without a document', async () => { - createApp({ uid: 'u2' }) - await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: true, document: null }) - }) - - it('rejects malformed or oversized documents', async () => { - const { docs } = createApp({ uid: 'u1' }) - const bad = [ - null, - { ...document, version: 2 }, - { ...document, favorites: 'x' }, - { ...document, favorites: [1] }, - { ...document, histories: Array.from({ length: fn._internal.LIST_LIMITS.histories! + 1 }, () => ({})) }, - { ...document, historyClearedAt: -1 }, - { ...document, favorites: [{ id: 1, note: 'x'.repeat(fn._internal.MAX_DOCUMENT_BYTES) }] }, - ] - for (const input of bad) - await expect(fn.main({ action: 'set', document: input })).resolves.toEqual({ ok: false, code: 'INVALID_DOCUMENT' }) - expect(docs.size).toBe(0) - }) - - it('drops unknown top-level fields', () => { - expect(fn._internal.readDocument({ ...document, _id: 'victim', admin: true })).toEqual({ ...document }) - }) - - it('rejects unknown actions and hides internal errors', async () => { - createApp({ uid: 'u1' }) - await expect(fn.main({ action: 'drop' })).resolves.toEqual({ ok: false, code: 'UNKNOWN_ACTION' }) - - fn._internal.setAppForTest({ - auth: () => ({ getUserInfo: () => ({ uid: 'u1' }) }), - database: () => { - throw new Error('db down') - }, - }) - const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - await expect(fn.main({ action: 'get' })).resolves.toEqual({ ok: false, code: 'INTERNAL_ERROR' }) - error.mockRestore() - }) -}) diff --git a/test/userDataStore.test.ts b/test/userDataStore.test.ts index 3e0b33a..50b5266 100644 --- a/test/userDataStore.test.ts +++ b/test/userDataStore.test.ts @@ -1,15 +1,12 @@ -import type { RecipeItem } from '../apps/web/app/types' -import type { DbRecipeItem } from '../apps/web/app/utils/db' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createCloudbaseUserDataRemote, USER_DATA_FUNCTION } from '../apps/web/app/adapters/userData' import { favoriteEntries, setFavoriteEntries, toggleFavorite } from '../apps/web/app/composables/store/favorite' import { addRecipeHistory, clearRecipeHistories, recipeHistories, removeRecipeHistory } from '../apps/web/app/composables/store/history' -import { exportLocalUserData, importLocalUserData, localUserData } from '../apps/web/app/composables/store/userData' +import { exportLocalUserData, importLocalUserData } from '../apps/web/app/composables/store/userData' import { userDataMeta } from '../apps/web/app/composables/store/userDataMeta' const NOW = 1_800_000_000_000 -function recipe(name: string, id?: number): DbRecipeItem { +function recipe(name: string, id?: number) { return { id, name, stuff: [], tools: [] } } @@ -17,7 +14,7 @@ beforeEach(() => { vi.useFakeTimers({ now: NOW }) setFavoriteEntries([]) recipeHistories.value = [] - userDataMeta.value = { favoriteRemovals: [], historyRemovals: [], historyClearedAt: 0, lastUid: '' } + userDataMeta.value = { favoriteRemovals: [], historyRemovals: [], historyClearedAt: 0 } return () => vi.useRealTimers() }) @@ -88,51 +85,3 @@ describe('export / import', () => { expect(favoriteEntries.value).toEqual([]) }) }) - -describe('localUserData', () => { - it('only rewrites the last uid when it changes', () => { - const before = userDataMeta.value - localUserData.writeLastUid('') - expect(userDataMeta.value).toBe(before) - localUserData.writeLastUid('u1') - expect(localUserData.readLastUid()).toBe('u1') - }) -}) - -describe('createCloudbaseUserDataRemote', () => { - const document = { - version: 1 as const, - favorites: [{ id: 1, time: NOW }], - favoriteRemovals: [], - histories: [{ recipe: { name: 'A', stuff: [], tools: [] } as RecipeItem, time: NOW }], - historyRemovals: [], - historyClearedAt: 0, - updatedAt: NOW, - } - - it('reads through the cook-user-data function without naming a uid', async () => { - const callFunction = vi.fn(async () => ({ ok: true, document: { favorites: [] } })) - const remote = createCloudbaseUserDataRemote(callFunction) - - await expect(remote.read('u1')).resolves.toEqual({ favorites: [] }) - expect(callFunction).toHaveBeenCalledWith(USER_DATA_FUNCTION, { action: 'get' }) - }) - - it('returns null when the account has no document', async () => { - const remote = createCloudbaseUserDataRemote(async () => ({ ok: true, document: null })) - await expect(remote.read('u1')).resolves.toBeNull() - }) - - it('writes the whole document', async () => { - const callFunction = vi.fn(async () => ({ ok: true, updatedAt: NOW })) - await createCloudbaseUserDataRemote(callFunction).write('u1', document) - expect(callFunction).toHaveBeenCalledWith(USER_DATA_FUNCTION, { action: 'set', document }) - }) - - it('surfaces function failures', async () => { - const remote = createCloudbaseUserDataRemote(async () => ({ ok: false, code: 'LOGIN_REQUIRED' })) - await expect(remote.read('u1')).rejects.toThrow('LOGIN_REQUIRED') - await expect(remote.write('u1', document)).rejects.toThrow('LOGIN_REQUIRED') - await expect(createCloudbaseUserDataRemote(async () => null).read('u1')).rejects.toThrow('unknown') - }) -}) diff --git a/test/userDataSync.test.ts b/test/userDataSync.test.ts deleted file mode 100644 index d3862a3..0000000 --- a/test/userDataSync.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { UserData } from '../apps/web/app/utils/userData' -import type { UserDataDocument, UserDataLocal, UserDataRemote } from '../apps/web/app/utils/userDataSync' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createEmptyUserData } from '../apps/web/app/utils/userData' -import { createUserDataSyncScheduler, syncUserData, UserDataSyncError } from '../apps/web/app/utils/userDataSync' - -const NOW = 1_800_000_000_000 - -function createLocal(initial: Partial = {}, lastUid = '') { - const state = { data: { ...createEmptyUserData(), ...initial }, lastUid, writes: 0 } - const local: UserDataLocal = { - read: () => state.data, - write: (data) => { - state.data = data - state.writes += 1 - }, - readLastUid: () => state.lastUid, - writeLastUid: (uid) => { - state.lastUid = uid - }, - } - return { local, state } -} - -function createRemote(initial: Record = {}) { - const docs = new Map(Object.entries(initial)) - const write = vi.fn(async (uid: string, document: UserDataDocument) => { - docs.set(uid, JSON.parse(JSON.stringify(document))) - }) - const remote: UserDataRemote = { - read: async uid => docs.get(uid) ?? null, - write, - } - return { docs, remote, write } -} - -describe('syncUserData', () => { - it('merges local data into a new cloud document on first sync', async () => { - const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 1 }] }) - const { docs, remote } = createRemote() - - await syncUserData('u1', local, remote, NOW) - - expect(docs.get('u1')).toMatchObject({ version: 1, updatedAt: NOW, favorites: [{ id: 1, time: NOW - 1 }] }) - expect(state.lastUid).toBe('u1') - expect(state.writes).toBe(0) - }) - - it('merges cloud and local for the same account', async () => { - const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 2 }] }, 'u1') - const { docs, remote } = createRemote({ u1: { version: 1, favorites: [{ id: 2, time: NOW - 1 }] } }) - - await syncUserData('u1', local, remote, NOW) - - expect(state.data.favorites.map(f => f.id)).toEqual([1, 2]) - expect((docs.get('u1') as UserData).favorites.map(f => f.id)).toEqual([1, 2]) - }) - - it('does not write anything when both sides already agree', async () => { - const value = { favorites: [{ id: 1, time: NOW - 1 }] } - const { local, state } = createLocal(value, 'u1') - const { remote, write } = createRemote({ u1: { version: 1, ...value } }) - - await syncUserData('u1', local, remote, NOW) - - expect(write).not.toHaveBeenCalled() - expect(state.writes).toBe(0) - }) - - it('replaces local data instead of merging after an account switch', async () => { - const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 2 }] }, 'u1') - const { docs, remote, write } = createRemote({ u2: { version: 1, favorites: [{ id: 2, time: NOW - 1 }] } }) - - await syncUserData('u2', local, remote, NOW) - - expect(state.data.favorites.map(f => f.id)).toEqual([2]) - expect(state.lastUid).toBe('u2') - expect(write).not.toHaveBeenCalled() - expect(docs.has('u1')).toBe(false) - }) - - it('never overwrites an unrecognised cloud document', async () => { - const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 1 }] }) - const { remote, write } = createRemote({ u1: { version: 99 } }) - - await expect(syncUserData('u1', local, remote, NOW)).rejects.toMatchObject({ code: 'invalid_remote' }) - expect(write).not.toHaveBeenCalled() - expect(state.lastUid).toBe('') - }) - - it('keeps local data when the cloud is unreachable', async () => { - const { local, state } = createLocal({ favorites: [{ id: 1, time: NOW - 1 }] }) - const remote: UserDataRemote = { - read: async () => { - throw new Error('offline') - }, - write: vi.fn(), - } - - const error = await syncUserData('u1', local, remote, NOW).catch(e => e) - expect(error).toBeInstanceOf(UserDataSyncError) - expect(error.code).toBe('remote_failed') - expect(state.data.favorites).toEqual([{ id: 1, time: NOW - 1 }]) - }) -}) - -describe('createUserDataSyncScheduler', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - it('coalesces requests made before the delay elapses', async () => { - const run = vi.fn(async () => {}) - const scheduler = createUserDataSyncScheduler({ run }) - - scheduler.request(2000) - scheduler.request(2000) - await vi.advanceTimersByTimeAsync(1999) - expect(run).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(run).toHaveBeenCalledTimes(1) - }) - - it('runs again after a request made during a run', async () => { - let finish!: () => void - const run = vi.fn(() => new Promise((resolve) => { - finish = resolve - })) - const scheduler = createUserDataSyncScheduler({ run }) - - scheduler.request() - await vi.advanceTimersByTimeAsync(0) - scheduler.request(2000) - finish() - await vi.advanceTimersByTimeAsync(0) - expect(run).toHaveBeenCalledTimes(2) - }) - - it('retries failures with exponential backoff', async () => { - const onError = vi.fn() - const run = vi.fn() - .mockRejectedValueOnce(new Error('a')) - .mockRejectedValueOnce(new Error('b')) - .mockResolvedValue(undefined) - const scheduler = createUserDataSyncScheduler({ run, onError }) - - scheduler.request() - await vi.advanceTimersByTimeAsync(0) - expect(run).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1000) - expect(run).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1999) - expect(run).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1) - expect(run).toHaveBeenCalledTimes(3) - await vi.advanceTimersByTimeAsync(60_000) - expect(run).toHaveBeenCalledTimes(3) - expect(onError).toHaveBeenCalledTimes(2) - }) - - it('stops retrying after stop()', async () => { - const run = vi.fn().mockRejectedValue(new Error('offline')) - const scheduler = createUserDataSyncScheduler({ run }) - - scheduler.request() - await vi.advanceTimersByTimeAsync(0) - scheduler.stop() - await vi.advanceTimersByTimeAsync(60_000) - expect(run).toHaveBeenCalledTimes(1) - }) -}) - -describe('cook_user_data permission', () => { - it('keeps the collection admin-only so only the cloud function can access it', async () => { - const { createRequire } = await import('node:module') - const fn = createRequire(import.meta.url)('../cloudfunctions/cook-user-data/index.js') as { _internal: { COLLECTION: string } } - const { default: rule } = await import('../scripts/cloudbase-rules/cook_user_data.json') - expect(rule.resourceId).toBe(fn._internal.COLLECTION) - expect(rule.permission).toBe('ADMINONLY') - expect(rule).not.toHaveProperty('securityRule') - }) -}) diff --git a/test/yunlefunAuth.test.ts b/test/yunlefunAuth.test.ts deleted file mode 100644 index 6b4f085..0000000 --- a/test/yunlefunAuth.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { SsoAuthorizationResult } from '@yunlefun/sso' -import type { - YunlefunAccount, - YunlefunAuthorizationAdapter, - YunlefunIdentityAdapter, -} from '../apps/web/app/utils/yunlefunAuth' -import { describe, expect, it } from 'vitest' -import { - createYunlefunAuthController, -} from '../apps/web/app/utils/yunlefunAuth' - -const authorization: SsoAuthorizationResult = { - ok: true, - clientId: 'cook-web', - code: 'c'.repeat(43), - codeVerifier: 'v'.repeat(43), - issuer: 'https://www.yunle.fun', - nonce: 'n'.repeat(43), - redirectUri: 'https://cook.yunyoujun.cn/auth/callback', - scope: ['identity:bootstrap'], -} - -const account: YunlefunAccount = { - displayName: '云游君', - uid: 'user-1', -} - -describe('yunlefun auth controller', () => { - it('adopts an initial authorization result through its public state', async () => { - const web = fakeAuthorizationAdapter(authorization) - const identity = fakeIdentityAdapter() - const controller = createYunlefunAuthController({ - identity, - web, - }) - - await controller.initialize() - - expect(controller.snapshot()).toEqual({ - account, - errorMessage: '', - status: 'signed-in', - }) - expect(identity.adopted).toEqual([authorization]) - }) - - it('opens the web authorization surface', async () => { - const web = fakeAuthorizationAdapter() - const controller = createYunlefunAuthController({ - identity: fakeIdentityAdapter(), - web, - }) - - await controller.signIn() - - expect(web.started).toBe(1) - expect(controller.snapshot().status).toBe('signing-in') - }) - - it('adopts a host authorization without leaving the current page', async () => { - const web = fakeAuthorizationAdapter(null, authorization) - const identity = fakeIdentityAdapter() - const controller = createYunlefunAuthController({ - identity, - web, - }) - - await controller.signIn() - - expect(identity.adopted).toEqual([authorization]) - expect(controller.snapshot()).toEqual({ - account, - errorMessage: '', - status: 'signed-in', - }) - }) - - it('clears both the identity session and public account on sign-out', async () => { - const identity = fakeIdentityAdapter(account) - const controller = createYunlefunAuthController({ - identity, - web: fakeAuthorizationAdapter(), - }) - await controller.initialize() - - await controller.signOut() - - expect(identity.signOutCount).toBe(1) - expect(controller.snapshot()).toEqual({ - account: null, - errorMessage: '', - status: 'signed-out', - }) - }) - - it('keeps the authenticated account visible when sign-out fails', async () => { - const identity = fakeIdentityAdapter(account) - identity.signOut = async () => { - identity.signOutCount += 1 - throw new Error('remote sign-out failed') - } - const controller = createYunlefunAuthController({ - identity, - web: fakeAuthorizationAdapter(), - }) - await controller.initialize() - - await expect(controller.signOut()).resolves.toBeUndefined() - - expect(controller.snapshot()).toEqual({ - account, - errorMessage: '退出登录失败,请稍后重试', - status: 'error', - }) - }) -}) - -function fakeAuthorizationAdapter( - initial: SsoAuthorizationResult | null = null, - startedResult: SsoAuthorizationResult | null = null, -): YunlefunAuthorizationAdapter & { started: number } { - return { - started: 0, - async consumeInitial() { - return initial - }, - async start() { - this.started += 1 - return startedResult - }, - } -} - -function fakeIdentityAdapter(restored: YunlefunAccount | null = null): YunlefunIdentityAdapter & { - adopted: SsoAuthorizationResult[] - signOutCount: number -} { - return { - adopted: [], - signOutCount: 0, - async adopt(nextAuthorization) { - this.adopted.push(nextAuthorization) - return account - }, - async restore() { - return restored - }, - async signOut() { - this.signOutCount += 1 - }, - } -} diff --git a/test/yunlefunAuthAdapter.test.ts b/test/yunlefunAuthAdapter.test.ts deleted file mode 100644 index acae39e..0000000 --- a/test/yunlefunAuthAdapter.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { AcquisitionContext, SsoAuthorizationResult } from '@yunlefun/sso' -import type { YunlefunSsoConfig } from '../apps/web/app/utils/yunlefunSsoConfig' -import { SsoIdentityAdoptionError } from '@yunlefun/sso/browser' -import { describe, expect, it, vi } from 'vitest' -import { - createCloudbaseIdentityAdapter, - createWebYunlefunAuthorizationAdapter, -} from '../apps/web/app/adapters/yunlefunAuth' - -const config: YunlefunSsoConfig = { - cloudbaseEnv: 'yunlefun-production', - exchange: { - exchangeUrl: 'https://api.yunle.fun/sso-ticket', - }, - redirect: { - clientId: 'cook-web', - redirectUri: 'https://cook.yunyoujun.cn/auth/callback', - scope: ['identity:bootstrap'], - ssoOrigin: 'https://www.yunle.fun', - }, -} - -const hostAuthorization: SsoAuthorizationResult = { - ok: true, - clientId: 'cook-web', - code: 'c'.repeat(43), - codeVerifier: 'v'.repeat(43), - issuer: 'https://www.yunle.fun', - nonce: 'n'.repeat(43), - redirectUri: 'https://cook.yunyoujun.cn/auth/callback', - scope: ['identity:bootstrap'], -} - -const touch = { - capturedAt: 1000, - landingHost: 'cook.yunle.fun', - landingPath: '/random', - referrerHost: 'www.baidu.com', - utmSource: 'baidu', - utmMedium: 'organic', - utmCampaign: '', -} -const acquisition: AcquisitionContext = { version: 1, firstTouch: touch, registerTouch: touch } - -describe('web authorization adapter', () => { - it('passes the same captured context to host authorization and redirect fallback', async () => { - const requestHostAuthorization = vi.fn(async () => null) - const startRedirect = vi.fn() - const adapter = createWebYunlefunAuthorizationAdapter(config, { - readAcquisition: () => acquisition, - requestHostAuthorization, - startRedirect, - }) - await adapter.start() - expect(requestHostAuthorization).toHaveBeenCalledWith({ ...config.redirect, prompt: 'consent', acquisition }) - expect(startRedirect).toHaveBeenCalledWith({ ...config.redirect, acquisition }) - }) - - it('uses the YunLeFun host consent sheet without starting a redirect', async () => { - const requestHostAuthorization = vi.fn(async () => hostAuthorization) - const startRedirect = vi.fn() - const adapter = createWebYunlefunAuthorizationAdapter(config, { - requestHostAuthorization, - startRedirect, - }) - - await expect(adapter.start()).resolves.toEqual(hostAuthorization) - expect(requestHostAuthorization).toHaveBeenCalledWith({ - ...config.redirect, - prompt: 'consent', - }) - expect(startRedirect).not.toHaveBeenCalled() - }) - - it('falls back to the top-level web redirect outside the YunLeFun host', async () => { - const startRedirect = vi.fn() - const adapter = createWebYunlefunAuthorizationAdapter(config, { - requestHostAuthorization: async () => null, - startRedirect, - }) - - await expect(adapter.start()).resolves.toBeNull() - expect(startRedirect).toHaveBeenCalledWith(config.redirect) - }) - - it('does not bypass an explicit host denial with a web redirect', async () => { - const startRedirect = vi.fn() - const adapter = createWebYunlefunAuthorizationAdapter(config, { - requestHostAuthorization: async () => { - throw new SsoIdentityAdoptionError( - 'Host authorization was denied', - 'access_denied', - ) - }, - startRedirect, - }) - - await expect(adapter.start()).resolves.toEqual({ - ok: false, - reason: 'access_denied', - }) - expect(startRedirect).not.toHaveBeenCalled() - }) -}) - -describe('cloudbase identity adapter', () => { - it('rejects a sign-out response that contains a CloudBase error', async () => { - const identity = createCloudbaseIdentityAdapter(config, { - getAuth: async () => ({ - getSession: async () => ({ data: { session: null } }), - getUser: async () => ({ data: { user: null } }), - signInWithCustomTicket: async () => ({}), - signOut: async () => ({ error: new Error('remote sign-out failed') }), - }), - }) - - await expect(identity.signOut()).rejects.toThrow('CloudBase sign-out failed') - }) -}) diff --git a/test/yunlefunSsoConfig.test.ts b/test/yunlefunSsoConfig.test.ts deleted file mode 100644 index 191e0a5..0000000 --- a/test/yunlefunSsoConfig.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveYunlefunSsoConfig } from '../apps/web/app/utils/yunlefunSsoConfig' - -const validConfig = { - clientId: 'cook-web', - cloudbaseEnv: 'yunlefun-8g7ybcxc7345c490', - exchangeUrl: 'https://api.yunle.fun/sso-ticket', - redirectUri: 'https://cook.yunyoujun.cn/auth/callback', - redirectUris: 'https://cook.yunyoujun.cn/auth/callback,https://cook.yunle.fun/auth/callback', - scope: 'identity:bootstrap', - ssoOrigin: 'https://www.yunle.fun', -} - -describe('resolveYunlefunSsoConfig', () => { - it('accepts the exact registered production web origin', () => { - expect(resolveYunlefunSsoConfig(validConfig, { - currentOrigin: 'https://cook.yunyoujun.cn', - })).toEqual({ - ok: true, - config: { - cloudbaseEnv: 'yunlefun-8g7ybcxc7345c490', - exchange: { - exchangeUrl: 'https://api.yunle.fun/sso-ticket', - }, - redirect: { - clientId: 'cook-web', - redirectUri: 'https://cook.yunyoujun.cn/auth/callback', - scope: ['identity:bootstrap'], - ssoOrigin: 'https://www.yunle.fun', - }, - }, - }) - }) - - it('selects the registered callback for the current production web origin', () => { - expect(resolveYunlefunSsoConfig(validConfig, { - currentOrigin: 'https://cook.yunle.fun', - })).toMatchObject({ - ok: true, - config: { - redirect: { - clientId: 'cook-web', - redirectUri: 'https://cook.yunle.fun/auth/callback', - }, - }, - }) - }) - - it('fails closed on an unregistered HTTP development origin', () => { - expect(resolveYunlefunSsoConfig(validConfig, { - currentOrigin: 'http://127.0.0.1:4173', - })).toEqual({ - ok: false, - message: '当前域名尚未登记为登录回跳地址', - }) - }) - - it('fails closed on an unregistered HTTPS origin', () => { - expect(resolveYunlefunSsoConfig(validConfig, { - currentOrigin: 'https://cook.example.com', - })).toEqual({ - ok: false, - message: '当前域名尚未登记为登录回跳地址', - }) - }) -})