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:
@@ -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<string, { previousValue: string | null | undefined, touch: AcquisitionTouch }>()
|
||||
|
||||
type AcquisitionBrowser = Pick<Window, 'location' | 'document' | 'localStorage' | 'sessionStorage'>
|
||||
|
||||
/** 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 : ''
|
||||
}
|
||||
@@ -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<unknown | null>
|
||||
write: (uid: string, document: UserDataDocument) => Promise<void>
|
||||
}
|
||||
|
||||
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<UserData> {
|
||||
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<void>
|
||||
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<typeof setTimeout> | 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<void> {
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<SsoRedirectResult, { ok: false }>
|
||||
|
||||
export interface YunlefunAuthorizationAdapter {
|
||||
consumeInitial: () => Promise<YunlefunAuthorizationResult | null>
|
||||
start: () => Promise<YunlefunAuthorizationResult | null>
|
||||
}
|
||||
|
||||
export interface YunlefunIdentityAdapter {
|
||||
adopt: (authorization: SsoAuthorizationResult) => Promise<YunlefunAccount | null>
|
||||
restore: () => Promise<YunlefunAccount | null>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface YunlefunAuthController {
|
||||
handleAuthorization: (authorization: YunlefunAuthorizationResult) => Promise<void>
|
||||
initialize: () => Promise<void>
|
||||
signIn: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
snapshot: () => Readonly<YunlefunAuthSnapshot>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!result.ok) {
|
||||
applyFailure(result.reason)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await adopt(result)
|
||||
}
|
||||
catch {
|
||||
update({
|
||||
account: null,
|
||||
errorMessage: '登录身份验证失败,请重新授权',
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut(): Promise<void> {
|
||||
try {
|
||||
await options.identity.signOut()
|
||||
update({
|
||||
account: null,
|
||||
errorMessage: '',
|
||||
status: 'signed-out',
|
||||
})
|
||||
}
|
||||
catch {
|
||||
update({
|
||||
errorMessage: '退出登录失败,请稍后重试',
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function adopt(result: SsoAuthorizationResult): Promise<void> {
|
||||
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<SsoRedirectResult, { ok: false }>['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<YunlefunAuthSnapshot>): void {
|
||||
state = { ...state, ...patch }
|
||||
}
|
||||
|
||||
return {
|
||||
handleAuthorization,
|
||||
initialize,
|
||||
signIn,
|
||||
signOut,
|
||||
snapshot: () => Object.freeze({ ...state }),
|
||||
}
|
||||
}
|
||||
@@ -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() : ''
|
||||
}
|
||||
Reference in New Issue
Block a user