mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
3064403c92
Keep historical identifiers readable and addressable while generating all new entity IDs with Base62. Reject unsafe external ID references and preserve API keys and device codes during optional normalization.
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import { waitUntil } from 'cloudflare:workers'
|
|
import { createCloudflareKvBackend } from '../server/adapters/cache/cloudflare-kv'
|
|
import { createRuntimeCache, resolveCacheMode } from '../server/adapters/cache/runtime-cache'
|
|
import { createArchiveJobsGateway } from '../server/adapters/gateways/archive-jobs'
|
|
import { createShareRepo } from '../server/adapters/repos/share'
|
|
import { createApp } from '../server/app'
|
|
import type { Auth } from '../server/auth'
|
|
import { createAuth } from '../server/auth'
|
|
import { createDeps } from '../server/composition'
|
|
import { isPotentialWebDavPublicRequest } from '../server/domain/webdav-public-url'
|
|
import { isHandledError, standaloneJsonError } from '../server/middleware/error-handler'
|
|
import { handleImageHostingDomainRequest } from '../server/middleware/image-hosting-domain'
|
|
import { createCloudflarePlatform } from '../server/platform/cloudflare'
|
|
import { platformContext } from '../server/platform/context'
|
|
import type { Deps } from '../server/usecases/deps'
|
|
import type { ArchiveJobMessage, CacheService } from '../server/usecases/ports'
|
|
import { DirType } from '../shared/constants'
|
|
import { handleScheduled } from './scheduled'
|
|
|
|
interface Env {
|
|
DB: D1Database
|
|
BETTER_AUTH_SECRET: string
|
|
BETTER_AUTH_URL?: string
|
|
TRUSTED_ORIGINS?: string
|
|
ASSETS: Fetcher
|
|
CACHE_KV?: KVNamespace
|
|
[key: string]: unknown
|
|
}
|
|
|
|
const SHARE_TOKEN_RE = /^\/s\/([^/?#]+)/
|
|
|
|
// Cache auth instances at isolate scope to avoid per-request DB queries and
|
|
// better-auth init CPU. createAuth resolves $context before returning, so the
|
|
// cache never carries a pending promise tied to its creating request (which
|
|
// would hang every later auth call in the isolate). Fixed slots prevent a first
|
|
// request on the WebDAV hostname from fixing the primary app base URL without
|
|
// allowing arbitrary Host headers to grow the cache. Changes to OAuth provider
|
|
// configs or env vars take effect on isolate recycle.
|
|
type AuthSlot = 'configured' | 'primary' | 'webdav'
|
|
|
|
interface WorkerRuntime {
|
|
platform: ReturnType<typeof createCloudflarePlatform>
|
|
deps: Deps
|
|
cache: CacheService
|
|
authBySlot: Map<AuthSlot, Auth>
|
|
appBySlot: Map<AuthSlot, ReturnType<typeof createApp>>
|
|
appInitBySlot: Map<AuthSlot, Promise<ReturnType<typeof createApp>>>
|
|
}
|
|
|
|
let cachedRuntime: WorkerRuntime | undefined
|
|
const responseCache = (
|
|
caches as unknown as {
|
|
default: {
|
|
match(request: Request): Promise<Response | undefined>
|
|
put(request: Request, response: Response): Promise<void>
|
|
}
|
|
}
|
|
).default
|
|
|
|
function runtimeFor(env: Env): WorkerRuntime {
|
|
if (cachedRuntime) return cachedRuntime
|
|
|
|
const platform = createCloudflarePlatform(env)
|
|
const distributed = env.CACHE_KV ? createCloudflareKvBackend(env.CACHE_KV) : undefined
|
|
const cache = createRuntimeCache({
|
|
mode: resolveCacheMode(env.ZPAN_CACHE_MODE as string | undefined, !!distributed),
|
|
distributed,
|
|
})
|
|
cachedRuntime = {
|
|
platform,
|
|
cache,
|
|
deps: createDeps(platform, { cache }),
|
|
authBySlot: new Map(),
|
|
appBySlot: new Map(),
|
|
appInitBySlot: new Map(),
|
|
}
|
|
return cachedRuntime
|
|
}
|
|
|
|
async function appForRequest(
|
|
runtime: WorkerRuntime,
|
|
request: Request,
|
|
env: Env,
|
|
): Promise<ReturnType<typeof createApp>> {
|
|
const origin = new URL(request.url).origin
|
|
const webDavRequest = isPotentialWebDavPublicRequest(request.url)
|
|
const inferredOrigin = origin
|
|
const baseURL = env.BETTER_AUTH_URL || inferredOrigin
|
|
const trustedOrigins = env.TRUSTED_ORIGINS?.split(',')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean) || [inferredOrigin]
|
|
const slot: AuthSlot = env.BETTER_AUTH_URL ? 'configured' : webDavRequest ? 'webdav' : 'primary'
|
|
|
|
const cachedApp = runtime.appBySlot.get(slot)
|
|
const cachedAuth = runtime.authBySlot.get(slot)
|
|
if (cachedApp && cachedAuth) return cachedApp
|
|
|
|
const pendingApp = runtime.appInitBySlot.get(slot)
|
|
if (pendingApp) return pendingApp
|
|
|
|
const appPromise = createAuth(runtime.platform, env.BETTER_AUTH_SECRET, baseURL, trustedOrigins, waitUntil).then(
|
|
(auth) => {
|
|
const app = createApp(runtime.platform, auth, runtime.deps)
|
|
runtime.authBySlot.set(slot, auth)
|
|
runtime.appBySlot.set(slot, app)
|
|
return app
|
|
},
|
|
)
|
|
runtime.appInitBySlot.set(slot, appPromise)
|
|
try {
|
|
return await appPromise
|
|
} finally {
|
|
runtime.appInitBySlot.delete(slot)
|
|
}
|
|
}
|
|
|
|
export default {
|
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
const { BETTER_AUTH_SECRET } = env
|
|
if (!BETTER_AUTH_SECRET) {
|
|
throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.')
|
|
}
|
|
const runtime = runtimeFor(env)
|
|
const imageDomainResponse = await handleImageDomainBeforeAuth(request, env, runtime)
|
|
if (imageDomainResponse) return imageDomainResponse
|
|
const edgeCached = await matchConfigzResponseCache(request, runtime.cache)
|
|
if (edgeCached) return edgeCached
|
|
const app = await appForRequest(runtime, request, env)
|
|
|
|
return platformContext.run(runtime.platform, async () => {
|
|
const url = new URL(request.url)
|
|
const shareMatch = SHARE_TOKEN_RE.exec(url.pathname)
|
|
|
|
if (shareMatch && request.method === 'GET') {
|
|
return handleShareSsr(request, env, ctx, shareMatch[1], runtime.platform, app)
|
|
}
|
|
|
|
const response = await app.fetch(request, env, ctx)
|
|
cacheConfigzResponse(request, response, runtime.cache, ctx)
|
|
return response
|
|
})
|
|
},
|
|
|
|
async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
|
|
await handleScheduled(event, env)
|
|
},
|
|
|
|
async queue(batch: MessageBatch<ArchiveJobMessage>, env: Env): Promise<void> {
|
|
const runtime = runtimeFor(env)
|
|
const archiveJobs = createArchiveJobsGateway(runtime.platform)
|
|
for (const message of batch.messages) {
|
|
await archiveJobs.runMessage(message.body)
|
|
message.ack()
|
|
}
|
|
},
|
|
}
|
|
|
|
async function handleImageDomainBeforeAuth(
|
|
request: Request,
|
|
env: Env,
|
|
runtime: WorkerRuntime,
|
|
): Promise<Response | null> {
|
|
if (!isImageDomainFastPathRequest(request, env)) return null
|
|
|
|
try {
|
|
return await platformContext.run(runtime.platform, () =>
|
|
handleImageHostingDomainRequest({
|
|
request,
|
|
deps: runtime.deps,
|
|
platform: runtime.platform,
|
|
appHosts: [new URL(env.BETTER_AUTH_URL!).hostname.toLowerCase(), 'workers.dev'],
|
|
webDavMountPath: '/dav',
|
|
}),
|
|
)
|
|
} catch (error) {
|
|
if (!isHandledError(error)) console.error(`image_domain.unhandled_error code=${String(error)}`)
|
|
return standaloneJsonError(error)
|
|
}
|
|
}
|
|
|
|
export function isImageDomainFastPathRequest(request: Request, env: Pick<Env, 'BETTER_AUTH_URL'>): boolean {
|
|
if (!env.BETTER_AUTH_URL) return false
|
|
const url = new URL(request.url)
|
|
if (url.pathname !== '/ih' && !url.pathname.startsWith('/ih/')) return false
|
|
|
|
const host = (request.headers.get('host') ?? url.host).replace(/:\d+$/, '').toLowerCase()
|
|
const appHost = new URL(env.BETTER_AUTH_URL).hostname.toLowerCase()
|
|
return host !== appHost && !host.endsWith('.workers.dev')
|
|
}
|
|
|
|
function configzCacheKey(request: Request, includeValidators = false): Request | null {
|
|
if (request.method !== 'GET') return null
|
|
const url = new URL(request.url)
|
|
if (url.pathname !== '/api/configz' && url.pathname !== '/api/configz/') return null
|
|
url.search = ''
|
|
const ifNoneMatch = request.headers.get('If-None-Match')
|
|
return new Request(url.toString(), {
|
|
method: 'GET',
|
|
headers: includeValidators && ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : undefined,
|
|
})
|
|
}
|
|
|
|
async function matchConfigzResponseCache(request: Request, cache: CacheService): Promise<Response | null> {
|
|
if (cache.mode !== 'distributed') return null
|
|
const key = configzCacheKey(request, true)
|
|
if (!key) return null
|
|
try {
|
|
const matched = await responseCache.match(key)
|
|
if (!matched) return null
|
|
const response = new Response(matched.body, matched)
|
|
response.headers.set('X-ZPan-Cache', 'edge')
|
|
return response
|
|
} catch (error) {
|
|
console.error(
|
|
JSON.stringify({
|
|
message: 'cache.response.get.error',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}),
|
|
)
|
|
return null
|
|
}
|
|
}
|
|
|
|
function cacheConfigzResponse(request: Request, response: Response, cache: CacheService, ctx: ExecutionContext): void {
|
|
if (cache.mode !== 'distributed' || response.status !== 200) return
|
|
const key = configzCacheKey(request)
|
|
if (!key) return
|
|
const task = responseCache.put(key, response.clone()).catch((error: unknown) => {
|
|
console.error(
|
|
JSON.stringify({
|
|
message: 'cache.response.put.error',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}),
|
|
)
|
|
})
|
|
ctx.waitUntil(task)
|
|
}
|
|
|
|
interface ShareMeta {
|
|
title: string
|
|
description: string
|
|
imageUrl: string
|
|
}
|
|
|
|
async function fetchShareMeta(
|
|
platform: ReturnType<typeof createCloudflarePlatform>,
|
|
origin: string,
|
|
token: string,
|
|
): Promise<ShareMeta> {
|
|
const fallback: ShareMeta = {
|
|
title: 'Share unavailable',
|
|
description: 'Shared via ZPan',
|
|
imageUrl: `${origin}/logo-512.png`,
|
|
}
|
|
|
|
try {
|
|
const resolved = await createShareRepo(platform.db).resolveByToken(token)
|
|
if (resolved.status !== 'ok') return fallback
|
|
if (resolved.share.kind !== 'landing') return fallback
|
|
|
|
const { share, matter } = resolved
|
|
const expiry = share.expiresAt ? ` · Expires ${new Date(share.expiresAt).toLocaleDateString()}` : ''
|
|
const description = `Shared via ZPan${expiry}`
|
|
const isImage = matter.type.startsWith('image/') && matter.dirtype === DirType.FILE
|
|
|
|
return {
|
|
title: matter.name,
|
|
description,
|
|
imageUrl: isImage ? `${origin}/api/share/${token}/download` : `${origin}/logo-512.png`,
|
|
}
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
function escapeAttr(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"')
|
|
}
|
|
|
|
function buildOgTags(meta: ShareMeta, pageUrl: string): string {
|
|
return [
|
|
`<meta property="og:title" content="${escapeAttr(meta.title)}" />`,
|
|
`<meta property="og:description" content="${escapeAttr(meta.description)}" />`,
|
|
`<meta property="og:image" content="${escapeAttr(meta.imageUrl)}" />`,
|
|
`<meta property="og:type" content="website" />`,
|
|
`<meta property="og:url" content="${escapeAttr(pageUrl)}" />`,
|
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
|
`<meta name="twitter:title" content="${escapeAttr(meta.title)}" />`,
|
|
`<meta name="twitter:description" content="${escapeAttr(meta.description)}" />`,
|
|
`<meta name="twitter:image" content="${escapeAttr(meta.imageUrl)}" />`,
|
|
].join('\n ')
|
|
}
|
|
|
|
async function handleShareSsr(
|
|
request: Request,
|
|
env: Env,
|
|
ctx: ExecutionContext,
|
|
token: string,
|
|
platform: ReturnType<typeof createCloudflarePlatform>,
|
|
app: ReturnType<typeof createApp>,
|
|
): Promise<Response> {
|
|
const url = new URL(request.url)
|
|
const origin = url.origin
|
|
|
|
const [meta, spaRes] = await Promise.all([
|
|
fetchShareMeta(platform, origin, token),
|
|
env.ASSETS.fetch(new Request(`${origin}/index.html`, { headers: request.headers })),
|
|
])
|
|
|
|
if (!spaRes.ok) {
|
|
return app.fetch(request, env, ctx)
|
|
}
|
|
|
|
const html = await spaRes.text()
|
|
const ogTags = buildOgTags(meta, url.href)
|
|
const injected = html.replace(
|
|
'<title>ZPan</title>',
|
|
`<title>${meta.title.replace(/</g, '<')} — ZPan</title>\n ${ogTags}`,
|
|
)
|
|
|
|
return new Response(injected, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=UTF-8',
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
})
|
|
}
|