Files
zpan/workers/bootstrap.ts
T
Jasper Van 66b3ee3435 feat: public share landing page /s/:token + Workers SSR OG meta (#312)
* feat: public share landing page /s/:token + Workers SSR OG meta

- Add SPA route `/s/:token` (TanStack Router, outside _authenticated)
- Implement share components: ShareLanding, FilePreview, FolderBrowser,
  PasswordPrompt, SaveToDriveDialog, ShareError
- File preview: image/video/audio/PDF via object URL fetch; fallback for
  other types with download CTA
- Folder browser: breadcrumb navigation + children table with download
- Password gate: POST /api/share/:token/verify with error feedback
- Save to drive: workspace + folder picker, quota/password/gone error handling
- Workers SSR: inject OG meta tags for /s/:token requests (title, description,
  image, twitter:card); fetch share metadata via /api/share/:token
- Add /s/* to wrangler.toml run_worker_first for SSR routing
- Add zValidator to /:token/children endpoint for typed RPC query params
- Export ShareApiRoute type from server/app.ts; add RPC clients in rpc.ts
- Add share.* i18n keys (en + zh)
- 9 new unit tests covering error code derivation, escaping, i18n coverage

Agent-Profile: https://agent-kanban.dev/agents/b724a773425e397c

* test: add coverage for share public API wrappers and path traversal guard

- api.test.ts: add unit tests for getShareLanding, verifySharePassword,
  getShareChildren, saveShareToDrive (success + all error paths)
- share-public.integration.test.ts: add path traversal guard test
  (.. in path param returns 400 Invalid path)

Agent-Profile: https://agent-kanban.dev/agents/b724a773425e397c

* test: cover explicit page/pageSize params in children endpoint

Add integration test for GET /api/share/:token/children with explicit
page and pageSize query params to satisfy codecov/patch branch coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add error path coverage for children endpoint

Cover invalid token (404), trashed matter (410), and non-numeric
page/pageSize (NaN fallback) in GET /:token/children to satisfy
codecov/patch threshold requirements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add ASSETS binding to wrangler.toml for Workers SSR

Without binding = "ASSETS", env.ASSETS is undefined at runtime
and the /s/:token SSR handler throws error code 1101.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve CF SSR OG meta by calling service layer directly instead of self-subrequest

Cloudflare Workers cannot fetch() their own origin when the path is listed
in run_worker_first — the request loops back and returns a 500 error code 1101.
Replace the HTTP subrequest in fetchShareMeta with a direct call to
resolveShareByToken(platform.db, token) from the service layer.

Add CF integration tests asserting that a valid landing share produces real
og:title metadata and an unknown token falls back gracefully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 13:16:20 -04:00

141 lines
4.4 KiB
TypeScript

import { createApp } from '../server/app'
import type { Auth } from '../server/auth'
import { createAuth } from '../server/auth'
import { createCloudflarePlatform } from '../server/platform/cloudflare'
import { resolveShareByToken } from '../server/services/share'
import { DirType } from '../shared/constants'
interface Env {
DB: D1Database
BETTER_AUTH_SECRET: string
BETTER_AUTH_URL?: string
TRUSTED_ORIGINS?: string
ASSETS: Fetcher
[key: string]: unknown
}
// Cache auth instance at isolate scope to avoid per-request DB queries
// for OIDC config loading. Changes to OIDC provider configs or env vars
// (BETTER_AUTH_URL, TRUSTED_ORIGINS) take effect on isolate recycle.
let cachedAuth: Auth | null = null
const SHARE_TOKEN_RE = /^\/s\/([^/?#]+)/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { BETTER_AUTH_SECRET } = env
if (!BETTER_AUTH_SECRET) {
throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.')
}
const platform = createCloudflarePlatform(env)
if (!cachedAuth) {
const origin = new URL(request.url).origin
const baseURL = env.BETTER_AUTH_URL || origin
const trustedOrigins = env.TRUSTED_ORIGINS?.split(',')
.map((o) => o.trim())
.filter(Boolean) || [origin]
cachedAuth = await createAuth(platform.db, BETTER_AUTH_SECRET, baseURL, trustedOrigins)
}
const url = new URL(request.url)
const shareMatch = SHARE_TOKEN_RE.exec(url.pathname)
if (shareMatch && request.method === 'GET') {
return handleShareSsr(request, env, shareMatch[1], platform, cachedAuth)
}
return createApp(platform, cachedAuth).fetch(request)
},
}
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 resolveShareByToken(platform.db, 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, '&amp;').replace(/"/g, '&quot;')
}
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,
token: string,
platform: ReturnType<typeof createCloudflarePlatform>,
auth: Auth,
): 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 createApp(platform, auth).fetch(request)
}
const html = await spaRes.text()
const ogTags = buildOgTags(meta, url.href)
const injected = html.replace(
'<title>ZPan</title>',
`<title>${meta.title.replace(/</g, '&lt;')} — ZPan</title>\n ${ogTags}`,
)
return new Response(injected, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=UTF-8',
'Cache-Control': 'no-store',
},
})
}