refactor: 个人自用,移除登录与云同步全链路
站点只给本人用,云乐坊 SSO 授权、收藏/历史云同步、获客归因和账号切换记账都没有收益,却被登录状态检查和同步失败提示持续打扰。 - 删除 useYunlefunAuth / yunlefunAuth / yunlefunSsoConfig / yunlefunAuth 适配器、useUserDataSync / userDataSync / userData 适配器、三个 client 插件与 /auth/callback 回调页 - 抽出 adapters/cloudbase.ts,让「反馈一下」和匿名筛选信号不再依赖登录态;cook-feedback 云函数保留 - 删除 cook-user-data 云函数与 cook_user_data 权限规则,移除 @yunlefun/sso 依赖 - 「我的」页删掉登录按钮、账号卡、四个统计格和退出登录;「关于」页删掉项目作者、致谢名单和上游宣传链接 - 本地收藏/历史与导出导入保留,删除同步专用的 lastUid 记账 取舍见 docs/decisions/0005-drop-login-and-sync.md
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/** Calls a cloud function and resolves its `result`. */
|
||||
export type CloudbaseFunctionCaller = (name: string, data: Record<string, unknown>) => Promise<unknown>
|
||||
|
||||
interface CloudbaseApp {
|
||||
auth: (options?: { persistence: 'session' }) => unknown
|
||||
callFunction: (options: { name: string, data: Record<string, unknown> }) => 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<CloudbaseApp> | undefined
|
||||
|
||||
function getCloudbaseApp(env: string): Promise<CloudbaseApp> {
|
||||
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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>): Promise<FunctionResult> {
|
||||
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 } })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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<string>) => Promise<unknown>
|
||||
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<string, unknown>) => Promise<unknown>
|
||||
|
||||
interface CloudbaseApp {
|
||||
auth: (options?: { persistence: 'session' }) => CloudbaseAuthClient
|
||||
callFunction: (options: { name: string, data: Record<string, unknown> }) => 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<CloudbaseAuthClient>
|
||||
}
|
||||
|
||||
export interface WebYunlefunAuthorizationDependencies {
|
||||
requestHostAuthorization?: typeof requestHostSsoAuthorization
|
||||
startRedirect?: typeof startSsoRedirect
|
||||
readAcquisition?: () => AcquisitionContext | null
|
||||
}
|
||||
|
||||
let cachedCloudbaseEnv = ''
|
||||
let cachedCloudbaseApp: Promise<CloudbaseApp> | 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<CloudbaseApp> {
|
||||
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<CloudbaseAuthClient> {
|
||||
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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
// 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<YunlefunAccount | null> {
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user