mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
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>
This commit is contained in:
@@ -82,6 +82,7 @@ export type AppType = ReturnType<typeof createApp>
|
||||
|
||||
// Sub-router types for RPC clients — avoids combined AppType OOM
|
||||
export type ObjectsRoute = typeof objects
|
||||
export type ShareApiRoute = typeof shareApi
|
||||
export type SharesRoute = typeof shares
|
||||
export type TrashRoute = typeof trash
|
||||
export type StoragesRoute = typeof storages
|
||||
|
||||
+56
-45
@@ -134,61 +134,72 @@ const app = new Hono<Env>()
|
||||
res.headers.set('Cache-Control', 'no-store')
|
||||
return res
|
||||
})
|
||||
.get('/:token/children', async (c) => {
|
||||
const token = c.req.param('token')
|
||||
const db = c.get('platform').db
|
||||
.get(
|
||||
'/:token/children',
|
||||
zValidator(
|
||||
'query',
|
||||
z.object({
|
||||
path: z.string().optional(),
|
||||
page: z.string().optional(),
|
||||
pageSize: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const token = c.req.param('token')
|
||||
const db = c.get('platform').db
|
||||
|
||||
const resolved = await resolveShareByToken(db, token)
|
||||
if (resolved.status !== 'ok') {
|
||||
if (resolved.status === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410)
|
||||
return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
}
|
||||
const resolved = await resolveShareByToken(db, token)
|
||||
if (resolved.status !== 'ok') {
|
||||
if (resolved.status === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410)
|
||||
return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
}
|
||||
|
||||
const { share, matter, recipients } = resolved
|
||||
if (share.kind !== 'landing') return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
if (matter.dirtype === DirType.FILE) return c.json({ error: 'Not a folder share' }, 400)
|
||||
const { share, matter, recipients } = resolved
|
||||
if (share.kind !== 'landing') return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
if (matter.dirtype === DirType.FILE) return c.json({ error: 'Not a folder share' }, 400)
|
||||
|
||||
const userId = await readUserId(c)
|
||||
const cookieVal = getCookie(c, cookieName(token))
|
||||
const gate = checkAccessGate(share.passwordHash, recipients, userId, cookieVal)
|
||||
if (gate === 'password_required') return c.json({ error: 'Password required' }, 401)
|
||||
const userId = await readUserId(c)
|
||||
const cookieVal = getCookie(c, cookieName(token))
|
||||
const gate = checkAccessGate(share.passwordHash, recipients, userId, cookieVal)
|
||||
if (gate === 'password_required') return c.json({ error: 'Password required' }, 401)
|
||||
|
||||
if (share.expiresAt && share.expiresAt < new Date()) return c.json({ error: 'Share has expired' }, 410)
|
||||
if (share.expiresAt && share.expiresAt < new Date()) return c.json({ error: 'Share has expired' }, 410)
|
||||
|
||||
const relativePath = c.req.query('path') ?? ''
|
||||
if (relativePath.includes('..')) return c.json({ error: 'Invalid path' }, 400)
|
||||
const { path: relativePath = '', page: rawPageStr = '1', pageSize: rawPageSizeStr = '50' } = c.req.valid('query')
|
||||
if (relativePath.includes('..')) return c.json({ error: 'Invalid path' }, 400)
|
||||
|
||||
const rawPage = parseInt(c.req.query('page') ?? '1', 10)
|
||||
const rawPageSize = parseInt(c.req.query('pageSize') ?? '50', 10)
|
||||
const page = Number.isNaN(rawPage) ? 1 : Math.max(1, rawPage)
|
||||
const pageSize = Number.isNaN(rawPageSize) ? 50 : Math.min(200, Math.max(1, rawPageSize))
|
||||
const rawPage = parseInt(rawPageStr, 10)
|
||||
const rawPageSize = parseInt(rawPageSizeStr, 10)
|
||||
const page = Number.isNaN(rawPage) ? 1 : Math.max(1, rawPage)
|
||||
const pageSize = Number.isNaN(rawPageSize) ? 50 : Math.min(200, Math.max(1, rawPageSize))
|
||||
|
||||
const root = folderRootPath(matter)
|
||||
const queryParent = relativePath ? `${root}/${relativePath}` : root
|
||||
const root = folderRootPath(matter)
|
||||
const queryParent = relativePath ? `${root}/${relativePath}` : root
|
||||
|
||||
const result = await listMatters(db, matter.orgId, {
|
||||
parent: queryParent,
|
||||
status: 'active',
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
const result = await listMatters(db, matter.orgId, {
|
||||
parent: queryParent,
|
||||
status: 'active',
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
|
||||
const items = result.items.map((m) => ({
|
||||
id: encodeChildRef(token, m.id),
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
isFolder: m.dirtype !== DirType.FILE,
|
||||
}))
|
||||
const items = result.items.map((m) => ({
|
||||
id: encodeChildRef(token, m.id),
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
isFolder: m.dirtype !== DirType.FILE,
|
||||
}))
|
||||
|
||||
return c.json({
|
||||
items,
|
||||
total: result.total,
|
||||
page,
|
||||
pageSize,
|
||||
breadcrumb: buildBreadcrumb(matter.name, relativePath),
|
||||
})
|
||||
})
|
||||
return c.json({
|
||||
items,
|
||||
total: result.total,
|
||||
page,
|
||||
pageSize,
|
||||
breadcrumb: buildBreadcrumb(matter.name, relativePath),
|
||||
})
|
||||
},
|
||||
)
|
||||
.get('/:token/download/:childRef', async (c) => {
|
||||
const token = c.req.param('token')
|
||||
const childRef = c.req.param('childRef')
|
||||
|
||||
@@ -368,6 +368,28 @@ describe('GET /s/:token/download', () => {
|
||||
// ─── GET /s/:token/children ────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /s/:token/children', () => {
|
||||
it('returns 404 for invalid token', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/share/no-such-token/children')
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 410 for trashed matter', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFolder(db, orgId, { id: 'trashed-dir', name: 'Gone', status: 'trashed' })
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, views, downloads, status, created_at)
|
||||
VALUES ('sh-trash-ch', 'token-trash-ch', 'landing', 'trashed-dir', ${orgId}, ${creatorId}, 0, 0, 'active', ${now})
|
||||
`)
|
||||
const res = await app.request('/api/share/token-trash-ch/children')
|
||||
expect(res.status).toBe(410)
|
||||
})
|
||||
|
||||
it('returns 400 for non-folder share', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
@@ -600,6 +622,57 @@ describe('GET /dl/:token', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── GET /s/:token/children — path traversal guard ───────────────────────────
|
||||
|
||||
describe('GET /s/:token/children — path traversal', () => {
|
||||
it('returns 400 when path contains ..', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFolder(db, orgId, { id: 'traversal-dir', name: 'Safe' })
|
||||
const share = await createShare(db, { matterId: 'traversal-dir', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
const res = await app.request(`/api/share/${share.token}/children?path=../etc`)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe('Invalid path')
|
||||
})
|
||||
|
||||
it('respects explicit page and pageSize query params', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFolder(db, orgId, { id: 'pg-dir', name: 'Paged' })
|
||||
const share = await createShare(db, { matterId: 'pg-dir', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
const res = await app.request(`/api/share/${share.token}/children?page=2&pageSize=10`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { page: number; pageSize: number }
|
||||
expect(body.page).toBe(2)
|
||||
expect(body.pageSize).toBe(10)
|
||||
})
|
||||
|
||||
it('falls back to defaults when page/pageSize are non-numeric', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFolder(db, orgId, { id: 'nan-dir', name: 'NaN' })
|
||||
const share = await createShare(db, { matterId: 'nan-dir', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
const res = await app.request(`/api/share/${share.token}/children?page=abc&pageSize=xyz`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { page: number; pageSize: number }
|
||||
expect(body.page).toBe(1)
|
||||
expect(body.pageSize).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── No auth required on /s routes ────────────────────────────────────────────
|
||||
|
||||
describe('public routes require no auth', () => {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Download, File } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { ShareLandingResponse } from '@/lib/api'
|
||||
import { formatSize } from '@/lib/format'
|
||||
|
||||
interface FilePreviewProps {
|
||||
token: string
|
||||
share: ShareLandingResponse
|
||||
onSaveToDrive?: () => void
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'other'
|
||||
|
||||
function detectPreviewKind(mimeType: string): PreviewKind {
|
||||
if (mimeType.startsWith('image/')) return 'image'
|
||||
if (mimeType.startsWith('video/')) return 'video'
|
||||
if (mimeType.startsWith('audio/')) return 'audio'
|
||||
if (mimeType === 'application/pdf') return 'pdf'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
interface MediaPreviewProps {
|
||||
token: string
|
||||
mimeType: string
|
||||
kind: PreviewKind
|
||||
}
|
||||
|
||||
function MediaPreview({ token, mimeType, kind }: MediaPreviewProps) {
|
||||
const { t } = useTranslation()
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const urlRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
|
||||
fetch(`/api/share/${token}/download`, { credentials: 'include', redirect: 'follow' })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('fetch failed')
|
||||
return res.blob()
|
||||
})
|
||||
.then((blob) => {
|
||||
if (cancelled) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
urlRef.current = url
|
||||
setObjectUrl(url)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (urlRef.current) {
|
||||
URL.revokeObjectURL(urlRef.current)
|
||||
urlRef.current = null
|
||||
}
|
||||
}
|
||||
}, [token])
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-64 items-center justify-center text-muted-foreground">{t('share.loading')}</div>
|
||||
}
|
||||
|
||||
if (error || !objectUrl) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<p>{t('share.previewUnavailable')}</p>
|
||||
<p className="text-sm">{t('share.previewDownloadHint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'image') {
|
||||
return <img src={objectUrl} alt="" className="max-h-[70vh] w-full rounded-md object-contain" />
|
||||
}
|
||||
|
||||
if (kind === 'video') {
|
||||
return (
|
||||
<video controls src={objectUrl} className="max-h-[70vh] w-full rounded-md">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'audio') {
|
||||
return (
|
||||
<audio controls src={objectUrl} className="w-full">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'pdf') {
|
||||
return <embed src={objectUrl} type="application/pdf" className="h-[70vh] w-full rounded-md border" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function FilePreview({ token, share, onSaveToDrive, isLoggedIn }: FilePreviewProps) {
|
||||
const { t } = useTranslation()
|
||||
const kind = detectPreviewKind(share.matterType)
|
||||
const downloadUrl = `/api/share/${token}/download`
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted">
|
||||
<File className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">{share.matterName}</h1>
|
||||
<p className="text-sm text-muted-foreground">{formatSize(share.matterSize)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isLoggedIn && onSaveToDrive && (
|
||||
<Button variant="outline" onClick={onSaveToDrive}>
|
||||
{t('share.saveToDrive')}
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild>
|
||||
<a href={downloadUrl} download>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('share.download')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card p-4">
|
||||
{kind === 'other' ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12 text-muted-foreground">
|
||||
<File className="h-16 w-16" />
|
||||
<p>{t('share.previewUnavailable')}</p>
|
||||
<p className="text-sm">{t('share.previewDownloadHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<MediaPreview token={token} mimeType={share.matterType} kind={kind} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronRight, Download, File, Folder, Home } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import type { ShareChildItem, ShareLandingResponse } from '@/lib/api'
|
||||
import { getShareChildren } from '@/lib/api'
|
||||
import { formatSize } from '@/lib/format'
|
||||
|
||||
interface FolderBrowserProps {
|
||||
token: string
|
||||
share: ShareLandingResponse
|
||||
onSaveToDrive?: () => void
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export function FolderBrowser({ token, share, onSaveToDrive, isLoggedIn }: FolderBrowserProps) {
|
||||
const { t } = useTranslation()
|
||||
const [currentPath, setCurrentPath] = useState('')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['share-children', token, currentPath],
|
||||
queryFn: () => getShareChildren(token, currentPath),
|
||||
})
|
||||
|
||||
const breadcrumb = query.data?.breadcrumb ?? []
|
||||
|
||||
function navigateInto(path: string) {
|
||||
setCurrentPath(path)
|
||||
}
|
||||
|
||||
function navigateToIndex(index: number) {
|
||||
if (index < 0) {
|
||||
setCurrentPath('')
|
||||
} else {
|
||||
const parts = currentPath.split('/').filter(Boolean)
|
||||
setCurrentPath(parts.slice(0, index + 1).join('/'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted">
|
||||
<Folder className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">{share.matterName}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('share.folderTitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isLoggedIn && onSaveToDrive && (
|
||||
<Button variant="outline" onClick={onSaveToDrive}>
|
||||
{t('share.saveToDrive')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4 flex items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigateToIndex(-1)}
|
||||
>
|
||||
<Home className="h-3.5 w-3.5" />
|
||||
<span>{share.matterName}</span>
|
||||
</button>
|
||||
{breadcrumb.map((crumb, idx) => (
|
||||
<span key={crumb.path} className="flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{idx === breadcrumb.length - 1 ? (
|
||||
<span className="font-medium">{crumb.name}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigateToIndex(idx)}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('share.colName')}</TableHead>
|
||||
<TableHead className="w-24">{t('share.colSize')}</TableHead>
|
||||
<TableHead className="w-24 text-right">{t('share.colActions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<TableRow key={n}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!query.isLoading &&
|
||||
(query.data?.items ?? []).map((item) => (
|
||||
<FolderRow
|
||||
key={item.id}
|
||||
token={token}
|
||||
item={item}
|
||||
currentPath={currentPath}
|
||||
onNavigate={navigateInto}
|
||||
/>
|
||||
))}
|
||||
{!query.isLoading && (query.data?.items ?? []).length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-8 text-center text-muted-foreground">
|
||||
{t('files.emptyState')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FolderRowProps {
|
||||
token: string
|
||||
item: ShareChildItem
|
||||
currentPath: string
|
||||
onNavigate: (path: string) => void
|
||||
}
|
||||
|
||||
function FolderRow({ token, item, currentPath, onNavigate }: FolderRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const childPath = currentPath ? `${currentPath}/${item.name}` : item.name
|
||||
const downloadUrl = `/api/share/${token}/download/${item.id}`
|
||||
|
||||
return (
|
||||
<TableRow className={item.isFolder ? 'cursor-pointer' : ''}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => item.isFolder && onNavigate(childPath)}
|
||||
disabled={!item.isFolder}
|
||||
>
|
||||
{item.isFolder ? (
|
||||
<Folder className="h-4 w-4 flex-shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<File className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className={item.isFolder ? 'font-medium hover:underline' : ''}>{item.name}</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{item.isFolder ? '—' : formatSize(item.size)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!item.isFolder && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href={downloadUrl} download title={t('share.download')}>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ApiError, verifySharePassword } from '@/lib/api'
|
||||
|
||||
interface PasswordPromptProps {
|
||||
token: string
|
||||
fileName: string
|
||||
onUnlocked: () => void
|
||||
}
|
||||
|
||||
export function PasswordPrompt({ token, fileName, onUnlocked }: PasswordPromptProps) {
|
||||
const { t } = useTranslation()
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
await verifySharePassword(token, password)
|
||||
onUnlocked()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setError(t('share.passwordWrong'))
|
||||
} else {
|
||||
setError(t('share.loadError'))
|
||||
}
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h1 className="mb-1 text-lg font-semibold">{t('share.passwordTitle')}</h1>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{fileName}</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t('share.passwordDesc')}</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="share-password">{t('share.passwordLabel')}</Label>
|
||||
<Input
|
||||
id="share-password"
|
||||
type="password"
|
||||
placeholder={t('share.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={pending || !password}>
|
||||
{t('share.passwordSubmit')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ApiError, listObjectsByPath, saveShareToDrive } from '@/lib/api'
|
||||
import { useListOrganizations } from '@/lib/auth-client'
|
||||
|
||||
interface SaveToDriveDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
token: string
|
||||
onPasswordRequired: () => void
|
||||
}
|
||||
|
||||
type Organization = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function SaveToDriveDialog({ open, onOpenChange, token, onPasswordRequired }: SaveToDriveDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data: orgs } = useListOrganizations()
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>('')
|
||||
const [selectedPath, setSelectedPath] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const allOrgs = (orgs ?? []) as Organization[]
|
||||
|
||||
const foldersQuery = useQuery({
|
||||
queryKey: ['folders-for-save', selectedOrgId],
|
||||
queryFn: () => listObjectsByPath('', 'active', 1, 200, { type: 'folder' }),
|
||||
enabled: !!selectedOrgId,
|
||||
})
|
||||
|
||||
const folders = (foldersQuery.data?.items ?? []).filter((item) => item.dirtype !== DirType.FILE)
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedOrgId) return
|
||||
setPending(true)
|
||||
try {
|
||||
const result = await saveShareToDrive(token, {
|
||||
targetOrgId: selectedOrgId,
|
||||
targetParent: selectedPath,
|
||||
})
|
||||
toast.success(t('share.saveSuccess', { count: result.saved.length }))
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 400 && err.body.code === 'QUOTA_EXCEEDED') {
|
||||
toast.error(t('share.quotaExceeded'))
|
||||
} else if (err.status === 401) {
|
||||
toast.error(t('share.passwordRequired'))
|
||||
onOpenChange(false)
|
||||
onPasswordRequired()
|
||||
} else if (err.status === 410) {
|
||||
toast.error(t('share.shareUnavailable'))
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(t('share.saveError'))
|
||||
}
|
||||
} else {
|
||||
toast.error(t('share.saveError'))
|
||||
}
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('share.saveToDriveTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('share.workspaceLabel')}</Label>
|
||||
<Select
|
||||
value={selectedOrgId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedOrgId(v)
|
||||
setSelectedPath('')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('share.workspacePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allOrgs.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedOrgId && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('share.folderLabel')}</Label>
|
||||
<Select value={selectedPath} onValueChange={setSelectedPath}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('share.folderPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('share.folderRoot')}</SelectItem>
|
||||
{folders.map((folder) => {
|
||||
const fullPath = folder.parent ? `${folder.parent}/${folder.name}` : folder.name
|
||||
return (
|
||||
<SelectItem key={folder.id} value={fullPath}>
|
||||
{fullPath}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={pending || !selectedOrgId}>
|
||||
{t('share.saveButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AlertCircle, Clock, Download, FileX } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type ShareErrorCode = 'not-found' | 'gone' | 'expired' | 'exhausted'
|
||||
|
||||
interface ShareErrorProps {
|
||||
code: ShareErrorCode
|
||||
}
|
||||
|
||||
const config: Record<ShareErrorCode, { titleKey: string; descKey: string; Icon: typeof AlertCircle }> = {
|
||||
'not-found': { titleKey: 'share.notFound', descKey: 'share.notFoundDesc', Icon: FileX },
|
||||
gone: { titleKey: 'share.gone', descKey: 'share.goneDesc', Icon: AlertCircle },
|
||||
expired: { titleKey: 'share.expired', descKey: 'share.expiredDesc', Icon: Clock },
|
||||
exhausted: { titleKey: 'share.exhausted', descKey: 'share.exhaustedDesc', Icon: Download },
|
||||
}
|
||||
|
||||
export function ShareError({ code }: ShareErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
const { titleKey, descKey, Icon } = config[code]
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-4 py-16">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="mb-2 text-2xl font-semibold">{t(titleKey)}</h1>
|
||||
<p className="text-muted-foreground">{t(descKey)}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<a href="/">{t('share.browseZPan')}</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ShareLandingResponse } from '@/lib/api'
|
||||
import { getShareLanding } from '@/lib/api'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { FilePreview } from './file-preview'
|
||||
import { FolderBrowser } from './folder-browser'
|
||||
import { PasswordPrompt } from './password-prompt'
|
||||
import { SaveToDriveDialog } from './save-to-drive-dialog'
|
||||
import type { ShareErrorCode } from './share-error'
|
||||
import { ShareError } from './share-error'
|
||||
|
||||
interface ShareLandingProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
function resolveError(share: ShareLandingResponse): ShareErrorCode | null {
|
||||
if (share.expired) return 'expired'
|
||||
if (share.exhausted) return 'exhausted'
|
||||
return null
|
||||
}
|
||||
|
||||
export function ShareLanding({ token }: ShareLandingProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: session } = useSession()
|
||||
const isLoggedIn = !!session?.user
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
|
||||
const [showPasswordAfterSave, setShowPasswordAfterSave] = useState(false)
|
||||
|
||||
const query = useQuery<ShareLandingResponse, { status?: number }>({
|
||||
queryKey: ['share-landing', token],
|
||||
queryFn: () => getShareLanding(token),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
function handleUnlocked() {
|
||||
queryClient.invalidateQueries({ queryKey: ['share-landing', token] })
|
||||
}
|
||||
|
||||
function handlePasswordRequired() {
|
||||
setShowPasswordAfterSave(true)
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-muted-foreground">{t('share.loading')}</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
const err = query.error as { status?: number }
|
||||
const code = err?.status === 410 ? 'gone' : 'not-found'
|
||||
return <ShareError code={code} />
|
||||
}
|
||||
|
||||
const share = query.data!
|
||||
|
||||
if (showPasswordAfterSave || share.requiresPassword) {
|
||||
return (
|
||||
<PasswordPrompt
|
||||
token={token}
|
||||
fileName={share.matterName}
|
||||
onUnlocked={() => {
|
||||
setShowPasswordAfterSave(false)
|
||||
handleUnlocked()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const errorCode = resolveError(share)
|
||||
if (errorCode) {
|
||||
return <ShareError code={errorCode} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{share.isFolder ? (
|
||||
<FolderBrowser
|
||||
token={token}
|
||||
share={share}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onSaveToDrive={isLoggedIn ? () => setSaveDialogOpen(true) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<FilePreview
|
||||
token={token}
|
||||
share={share}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onSaveToDrive={isLoggedIn ? () => setSaveDialogOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<SaveToDriveDialog
|
||||
open={saveDialogOpen}
|
||||
onOpenChange={setSaveDialogOpen}
|
||||
token={token}
|
||||
onPasswordRequired={handlePasswordRequired}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -463,5 +463,43 @@
|
||||
"share.done": "Done",
|
||||
"share.invalidExpiry": "Custom expiry must be a future date",
|
||||
"share.invalidLimit": "Download limit must be a positive number",
|
||||
"files.share": "Share"
|
||||
"files.share": "Share",
|
||||
"share.notFound": "Share Not Found",
|
||||
"share.notFoundDesc": "This share link doesn't exist or has been removed.",
|
||||
"share.gone": "File Deleted",
|
||||
"share.goneDesc": "The file behind this link has been permanently deleted.",
|
||||
"share.expired": "Share Expired",
|
||||
"share.expiredDesc": "This share link has expired.",
|
||||
"share.exhausted": "Download Limit Reached",
|
||||
"share.exhaustedDesc": "This share has reached its maximum number of downloads.",
|
||||
"share.browseZPan": "Browse ZPan",
|
||||
"share.passwordTitle": "Password Protected",
|
||||
"share.passwordDesc": "Enter the password to access this file.",
|
||||
"share.passwordLabel": "Password",
|
||||
"share.passwordPlaceholder": "Enter password",
|
||||
"share.passwordSubmit": "Unlock",
|
||||
"share.passwordWrong": "Wrong password. Please try again.",
|
||||
"share.download": "Download",
|
||||
"share.saveToDrive": "Save to My Drive",
|
||||
"share.sharedBy": "Shared by {{name}}",
|
||||
"share.folderTitle": "Shared Folder",
|
||||
"share.folderRoot": "Root",
|
||||
"share.colName": "Name",
|
||||
"share.colSize": "Size",
|
||||
"share.colActions": "Actions",
|
||||
"share.saveToDriveTitle": "Save to My Drive",
|
||||
"share.workspaceLabel": "Workspace",
|
||||
"share.workspacePlaceholder": "Select workspace",
|
||||
"share.folderLabel": "Destination Folder",
|
||||
"share.folderPlaceholder": "Select folder (optional, defaults to root)",
|
||||
"share.saveButton": "Save",
|
||||
"share.saveSuccess": "Saved {{count}} item(s)",
|
||||
"share.saveError": "Failed to save",
|
||||
"share.quotaExceeded": "Workspace is over quota",
|
||||
"share.passwordRequired": "Password required",
|
||||
"share.shareUnavailable": "This share is no longer available",
|
||||
"share.loading": "Loading share...",
|
||||
"share.loadError": "Failed to load share",
|
||||
"share.previewUnavailable": "Preview not available",
|
||||
"share.previewDownloadHint": "Download the file to view it"
|
||||
}
|
||||
|
||||
@@ -463,5 +463,43 @@
|
||||
"share.done": "完成",
|
||||
"share.invalidExpiry": "自定义过期时间必须是未来日期",
|
||||
"share.invalidLimit": "下载限额必须为正整数",
|
||||
"files.share": "分享"
|
||||
"files.share": "分享",
|
||||
"share.notFound": "分享不存在",
|
||||
"share.notFoundDesc": "此分享链接不存在或已被删除。",
|
||||
"share.gone": "文件已删除",
|
||||
"share.goneDesc": "此链接对应的文件已被永久删除。",
|
||||
"share.expired": "分享已过期",
|
||||
"share.expiredDesc": "此分享链接已超过有效期。",
|
||||
"share.exhausted": "下载次数已达上限",
|
||||
"share.exhaustedDesc": "此分享已达到最大下载次数。",
|
||||
"share.browseZPan": "浏览 ZPan",
|
||||
"share.passwordTitle": "需要密码",
|
||||
"share.passwordDesc": "请输入密码以访问此文件。",
|
||||
"share.passwordLabel": "密码",
|
||||
"share.passwordPlaceholder": "输入密码",
|
||||
"share.passwordSubmit": "解锁",
|
||||
"share.passwordWrong": "密码错误,请重试。",
|
||||
"share.download": "下载",
|
||||
"share.saveToDrive": "保存到我的网盘",
|
||||
"share.sharedBy": "分享者:{{name}}",
|
||||
"share.folderTitle": "共享文件夹",
|
||||
"share.folderRoot": "根目录",
|
||||
"share.colName": "名称",
|
||||
"share.colSize": "大小",
|
||||
"share.colActions": "操作",
|
||||
"share.saveToDriveTitle": "保存到我的网盘",
|
||||
"share.workspaceLabel": "工作区",
|
||||
"share.workspacePlaceholder": "选择工作区",
|
||||
"share.folderLabel": "目标文件夹",
|
||||
"share.folderPlaceholder": "选择文件夹(可选,默认为根目录)",
|
||||
"share.saveButton": "保存",
|
||||
"share.saveSuccess": "已保存 {{count}} 个文件",
|
||||
"share.saveError": "保存失败",
|
||||
"share.quotaExceeded": "工作区存储空间不足",
|
||||
"share.passwordRequired": "需要密码",
|
||||
"share.shareUnavailable": "此分享已不可用",
|
||||
"share.loading": "加载分享中...",
|
||||
"share.loadError": "加载分享失败",
|
||||
"share.previewUnavailable": "无法预览",
|
||||
"share.previewDownloadHint": "下载文件后查看"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
getProfile,
|
||||
getSession,
|
||||
getShare,
|
||||
getShareChildren,
|
||||
getShareLanding,
|
||||
getStorage,
|
||||
getSystemOption,
|
||||
getUnreadCount,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
restoreObject,
|
||||
saveShareToDrive,
|
||||
setSystemOption,
|
||||
trashObject,
|
||||
updateObject,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
updateStorage,
|
||||
updateUserStatus,
|
||||
uploadToS3,
|
||||
verifySharePassword,
|
||||
} from './api'
|
||||
|
||||
function makeResponse(body: unknown, ok = true, status = 200): Response {
|
||||
@@ -1094,4 +1098,141 @@ describe('api', () => {
|
||||
await expect(createShare({ matterId: 'obj-1', kind: 'landing' })).rejects.toThrow('unauthorized')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getShareLanding', () => {
|
||||
it('calls GET /api/share/:token and returns landing data', async () => {
|
||||
const payload = {
|
||||
kind: 'landing',
|
||||
matterName: 'photo.jpg',
|
||||
matterType: 'image/jpeg',
|
||||
matterSize: 1024,
|
||||
isFolder: false,
|
||||
requiresPassword: false,
|
||||
expired: false,
|
||||
exhausted: false,
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
downloads: 0,
|
||||
views: 1,
|
||||
creatorName: 'Alice',
|
||||
accessibleByUser: false,
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await getShareLanding('tok123')
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('/api/share/tok123')
|
||||
})
|
||||
|
||||
it('throws ApiError on 404', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Share not found or revoked' }, false, 404))
|
||||
|
||||
await expect(getShareLanding('bad-token')).rejects.toThrow('Share not found or revoked')
|
||||
})
|
||||
|
||||
it('throws ApiError on 410 (matter trashed)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'File no longer available' }, false, 410))
|
||||
|
||||
await expect(getShareLanding('bad-token')).rejects.toThrow('File no longer available')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifySharePassword', () => {
|
||||
it('calls POST /api/share/:token/verify with password', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ ok: true }))
|
||||
|
||||
const result = await verifySharePassword('tok123', 'secret')
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/share/tok123/verify')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ password: 'secret' })
|
||||
})
|
||||
|
||||
it('throws ApiError on 401 (wrong password)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Invalid password' }, false, 401))
|
||||
|
||||
await expect(verifySharePassword('tok123', 'wrong')).rejects.toThrow('Invalid password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getShareChildren', () => {
|
||||
it('calls GET /api/share/:token/children with default params', async () => {
|
||||
const payload = { items: [], total: 0, page: 1, pageSize: 50, breadcrumb: [] }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await getShareChildren('tok123')
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('/api/share/tok123/children')
|
||||
expect(url).toContain('page=1')
|
||||
expect(url).toContain('pageSize=50')
|
||||
})
|
||||
|
||||
it('passes custom path, page, and pageSize', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeResponse({ items: [], total: 0, page: 2, pageSize: 10, breadcrumb: [] }),
|
||||
)
|
||||
|
||||
await getShareChildren('tok123', 'Reports', 2, 10)
|
||||
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('path=Reports')
|
||||
expect(url).toContain('page=2')
|
||||
expect(url).toContain('pageSize=10')
|
||||
})
|
||||
|
||||
it('throws ApiError on 401 (password required)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Password required' }, false, 401))
|
||||
|
||||
await expect(getShareChildren('tok123')).rejects.toThrow('Password required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveShareToDrive', () => {
|
||||
it('calls POST /api/shares/:token/save with targetOrgId and targetParent', async () => {
|
||||
const payload = { saved: [{ id: 'obj-1', name: 'photo.jpg' }], skipped: [] }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
|
||||
|
||||
const result = await saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: 'Docs' })
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/shares/tok123/save')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ targetOrgId: 'org-1', targetParent: 'Docs' })
|
||||
})
|
||||
|
||||
it('throws ApiError with QUOTA_EXCEEDED code on 400', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeResponse({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, false, 400),
|
||||
)
|
||||
|
||||
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toThrow(
|
||||
'Quota exceeded',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws ApiError on 401 (password required)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeResponse({ error: 'Authentication required for password-protected share' }, false, 401),
|
||||
)
|
||||
|
||||
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toThrow(
|
||||
'Authentication required',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws ApiError on 410 (share gone)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Share target has been deleted' }, false, 410))
|
||||
|
||||
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toThrow(
|
||||
'Share target has been deleted',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
notificationsApi,
|
||||
objects,
|
||||
profiles,
|
||||
sharePublicApi,
|
||||
sharesApi,
|
||||
sharesSaveApi,
|
||||
storages,
|
||||
system,
|
||||
teamsApi,
|
||||
@@ -414,6 +416,72 @@ export async function getSession(): Promise<{ session: unknown; user: unknown }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Public Share Landing API
|
||||
|
||||
export interface ShareLandingResponse {
|
||||
kind: 'landing'
|
||||
matterName: string
|
||||
matterType: string
|
||||
matterSize: number
|
||||
isFolder: boolean
|
||||
requiresPassword: boolean
|
||||
expired: boolean
|
||||
exhausted: boolean
|
||||
expiresAt: string | null
|
||||
downloadLimit: number | null
|
||||
downloads: number
|
||||
views: number
|
||||
creatorName: string
|
||||
accessibleByUser: boolean
|
||||
}
|
||||
|
||||
export function getShareLanding(token: string) {
|
||||
return unwrap<ShareLandingResponse>(sharePublicApi[':token'].$get({ param: { token } }))
|
||||
}
|
||||
|
||||
export function verifySharePassword(token: string, password: string) {
|
||||
return unwrap<{ ok: boolean }>(sharePublicApi[':token'].verify.$post({ param: { token }, json: { password } }))
|
||||
}
|
||||
|
||||
export interface ShareChildItem {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
export interface ShareChildrenResponse {
|
||||
items: ShareChildItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
breadcrumb: Array<{ name: string; path: string }>
|
||||
}
|
||||
|
||||
export function getShareChildren(token: string, path = '', page = 1, pageSize = 50) {
|
||||
return unwrap<ShareChildrenResponse>(
|
||||
sharePublicApi[':token'].children.$get({
|
||||
param: { token },
|
||||
query: { path, page: String(page), pageSize: String(pageSize) },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export interface SaveShareInput {
|
||||
targetOrgId: string
|
||||
targetParent: string
|
||||
}
|
||||
|
||||
export interface SaveShareResult {
|
||||
saved: Array<{ id: string; name: string }>
|
||||
skipped: Array<{ name: string; reason: string }>
|
||||
}
|
||||
|
||||
export function saveShareToDrive(token: string, data: SaveShareInput) {
|
||||
return unwrap<SaveShareResult>(sharesSaveApi[':token'].save.$post({ param: { token }, json: data }))
|
||||
}
|
||||
|
||||
// S3 direct upload (external presigned URL, not our API)
|
||||
export function uploadToS3(url: string, file: File): Promise<void> {
|
||||
return fetch(url, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ObjectsRoute,
|
||||
ProfileRoute,
|
||||
PublicTeamsRoute,
|
||||
ShareApiRoute,
|
||||
SharesRoute,
|
||||
StoragesRoute,
|
||||
SystemRoute,
|
||||
@@ -34,3 +35,7 @@ export const teamsApi = hc<TeamsRoute>('/api/teams', opts)
|
||||
export const publicTeamsApi = hc<PublicTeamsRoute>('/api/teams')
|
||||
export const notificationsApi = hc<NotificationsRoute>('/api/notifications', opts)
|
||||
export const sharesApi = hc<SharesRoute>('/api/shares', opts)
|
||||
// Public share landing API (no auth required)
|
||||
export const sharePublicApi = hc<ShareApiRoute>('/api/share', opts)
|
||||
// Authenticated share save API
|
||||
export const sharesSaveApi = hc<SharesRoute>('/api/shares', opts)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
import { Route as UUsernameRouteImport } from './routes/u/$username'
|
||||
import { Route as STokenRouteImport } from './routes/s/$token'
|
||||
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
|
||||
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||
import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authenticated/settings/route'
|
||||
@@ -51,6 +52,11 @@ const UUsernameRoute = UUsernameRouteImport.update({
|
||||
path: '/u/$username',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const STokenRoute = STokenRouteImport.update({
|
||||
id: '/s/$token',
|
||||
path: '/s/$token',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authSignUpRoute = authSignUpRouteImport.update({
|
||||
id: '/(auth)/sign-up',
|
||||
path: '/sign-up',
|
||||
@@ -197,6 +203,7 @@ export interface FileRoutesByFullPath {
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/u/$username': typeof UUsernameRoute
|
||||
'/s/$token': typeof STokenRoute
|
||||
'/teams/$teamId': typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren
|
||||
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
|
||||
@@ -223,6 +230,7 @@ export interface FileRoutesByTo {
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/u/$username': typeof UUsernameRoute
|
||||
'/s/$token': typeof STokenRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
|
||||
@@ -252,6 +260,7 @@ export interface FileRoutesById {
|
||||
'/(auth)/sign-in': typeof authSignInRoute
|
||||
'/(auth)/sign-up': typeof authSignUpRoute
|
||||
'/u/$username': typeof UUsernameRoute
|
||||
'/s/$token': typeof STokenRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
'/_authenticated/teams/$teamId': typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren
|
||||
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
@@ -283,6 +292,7 @@ export interface FileRouteTypes {
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/u/$username'
|
||||
| '/s/$token'
|
||||
| '/teams/$teamId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/password'
|
||||
@@ -309,6 +319,7 @@ export interface FileRouteTypes {
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/u/$username'
|
||||
| '/s/$token'
|
||||
| '/'
|
||||
| '/settings/appearance'
|
||||
| '/settings/password'
|
||||
@@ -337,6 +348,7 @@ export interface FileRouteTypes {
|
||||
| '/(auth)/sign-in'
|
||||
| '/(auth)/sign-up'
|
||||
| '/u/$username'
|
||||
| '/s/$token'
|
||||
| '/_authenticated/'
|
||||
| '/_authenticated/teams/$teamId'
|
||||
| '/_authenticated/settings/appearance'
|
||||
@@ -365,10 +377,18 @@ export interface RootRouteChildren {
|
||||
authSignInRoute: typeof authSignInRoute
|
||||
authSignUpRoute: typeof authSignUpRoute
|
||||
UUsernameRoute: typeof UUsernameRoute
|
||||
STokenRoute: typeof STokenRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/s/$token': {
|
||||
id: '/s/$token'
|
||||
path: '/s/$token'
|
||||
fullPath: '/s/$token'
|
||||
preLoaderRoute: typeof STokenRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated': {
|
||||
id: '/_authenticated'
|
||||
path: ''
|
||||
@@ -660,6 +680,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
authSignInRoute: authSignInRoute,
|
||||
authSignUpRoute: authSignUpRoute,
|
||||
UUsernameRoute: UUsernameRoute,
|
||||
STokenRoute: STokenRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from '../../i18n/locales/en.json'
|
||||
import zh from '../../i18n/locales/zh.json'
|
||||
|
||||
// ShareLanding and its sub-components are React rendering components.
|
||||
// The project has no jsdom/@testing-library setup, so we test pure logic here.
|
||||
|
||||
// ─── Share error code derivation ─────────────────────────────────────────────
|
||||
|
||||
type ShareState = {
|
||||
expired: boolean
|
||||
exhausted: boolean
|
||||
requiresPassword: boolean
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
type ErrorCode = 'expired' | 'exhausted' | null
|
||||
|
||||
function resolveErrorCode(share: ShareState): ErrorCode {
|
||||
if (share.expired) return 'expired'
|
||||
if (share.exhausted) return 'exhausted'
|
||||
return null
|
||||
}
|
||||
|
||||
describe('resolveErrorCode', () => {
|
||||
it('returns expired when share is expired', () => {
|
||||
expect(resolveErrorCode({ expired: true, exhausted: false, requiresPassword: false, isFolder: false })).toBe(
|
||||
'expired',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns exhausted when download limit reached', () => {
|
||||
expect(resolveErrorCode({ expired: false, exhausted: true, requiresPassword: false, isFolder: false })).toBe(
|
||||
'exhausted',
|
||||
)
|
||||
})
|
||||
|
||||
it('prioritises expired over exhausted', () => {
|
||||
expect(resolveErrorCode({ expired: true, exhausted: true, requiresPassword: false, isFolder: false })).toBe(
|
||||
'expired',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for accessible share', () => {
|
||||
expect(resolveErrorCode({ expired: false, exhausted: false, requiresPassword: false, isFolder: false })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Workers SSR OG meta helper — attribute escaping ─────────────────────────
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
describe('escapeAttr', () => {
|
||||
it('escapes ampersands', () => {
|
||||
expect(escapeAttr('foo & bar')).toBe('foo & bar')
|
||||
})
|
||||
|
||||
it('escapes double quotes', () => {
|
||||
expect(escapeAttr('say "hello"')).toBe('say "hello"')
|
||||
})
|
||||
|
||||
it('leaves safe strings unchanged', () => {
|
||||
expect(escapeAttr('hello world')).toBe('hello world')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── i18n key coverage ────────────────────────────────────────────────────────
|
||||
// Verifies that the key set used by share components is consistent.
|
||||
|
||||
const REQUIRED_SHARE_KEYS = [
|
||||
'share.notFound',
|
||||
'share.gone',
|
||||
'share.expired',
|
||||
'share.exhausted',
|
||||
'share.browseZPan',
|
||||
'share.passwordTitle',
|
||||
'share.passwordSubmit',
|
||||
'share.passwordWrong',
|
||||
'share.download',
|
||||
'share.saveToDrive',
|
||||
'share.folderTitle',
|
||||
'share.saveToDriveTitle',
|
||||
'share.saveButton',
|
||||
'share.loading',
|
||||
'share.previewUnavailable',
|
||||
]
|
||||
|
||||
const enLocale = en as Record<string, string>
|
||||
const zhLocale = zh as Record<string, string>
|
||||
|
||||
describe('share i18n keys', () => {
|
||||
it('all required keys exist in en.json', () => {
|
||||
for (const key of REQUIRED_SHARE_KEYS) {
|
||||
expect(enLocale[key], `Missing key: ${key}`).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('all required keys exist in zh.json', () => {
|
||||
for (const key of REQUIRED_SHARE_KEYS) {
|
||||
expect(zhLocale[key], `Missing key in zh: ${key}`).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ShareLanding } from '@/components/share/share-landing'
|
||||
|
||||
export const Route = createFileRoute('/s/$token')({
|
||||
component: SharePage,
|
||||
})
|
||||
|
||||
function SharePage() {
|
||||
const { token } = Route.useParams()
|
||||
return <ShareLanding token={token} />
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import worker from './bootstrap'
|
||||
|
||||
const testEnv = { ...env, BETTER_AUTH_SECRET: env.BETTER_AUTH_SECRET || 'ci-test-secret-that-is-at-least-32-chars' }
|
||||
|
||||
const fakeSpaHtml = '<html><head><title>ZPan</title></head><body></body></html>'
|
||||
const fakeAssets = {
|
||||
fetch: (_req: RequestInfo | Request) =>
|
||||
Promise.resolve(new Response(fakeSpaHtml, { status: 200, headers: { 'Content-Type': 'text/html' } })),
|
||||
} as unknown as Fetcher
|
||||
|
||||
describe('[CF] Worker fetch handler', () => {
|
||||
it('throws when BETTER_AUTH_SECRET is missing', async () => {
|
||||
const request = new Request('http://localhost/api/health')
|
||||
@@ -27,3 +33,39 @@ describe('[CF] Worker fetch handler', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('[CF] SSR share OG meta injection', () => {
|
||||
it('injects real file name into og:title for valid landing share', async () => {
|
||||
const now = Date.now()
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
||||
VALUES ('ssr-matter-1', 'org-1', 'ssr-alias-1', 'design-spec.pdf', 'application/pdf', 4096, 0, '', 'obj/key.pdf', 'st-1', 'active', ?, ?)`,
|
||||
)
|
||||
.bind(now, now)
|
||||
.run()
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, password_hash, expires_at, download_limit, views, downloads, status, created_at)
|
||||
VALUES ('ssr-share-1', 'ssrtoken01', 'landing', 'ssr-matter-1', 'org-1', 'user-1', NULL, NULL, NULL, 0, 0, 'active', ?)`,
|
||||
)
|
||||
.bind(now)
|
||||
.run()
|
||||
|
||||
const testEnvWithAssets = { ...testEnv, ASSETS: fakeAssets }
|
||||
const res = await worker.fetch(new Request('http://localhost/s/ssrtoken01'), testEnvWithAssets)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const html = await res.text()
|
||||
expect(html).toContain('<meta property="og:title" content="design-spec.pdf"')
|
||||
expect(html).not.toContain('Share unavailable')
|
||||
})
|
||||
|
||||
it('returns fallback OG meta for unknown share token', async () => {
|
||||
const testEnvWithAssets = { ...testEnv, ASSETS: fakeAssets }
|
||||
const res = await worker.fetch(new Request('http://localhost/s/no-such-token'), testEnvWithAssets)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const html = await res.text()
|
||||
expect(html).toContain('<meta property="og:title" content="Share unavailable"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,15 @@ 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
|
||||
}
|
||||
|
||||
@@ -16,6 +19,8 @@ interface Env {
|
||||
// (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
|
||||
@@ -33,6 +38,103 @@ export default {
|
||||
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, '&').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,
|
||||
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, '<')} — ZPan</title>\n ${ogTags}`,
|
||||
)
|
||||
|
||||
return new Response(injected, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=UTF-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,8 +4,9 @@ compatibility_date = "2026-04-12"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = ["/api/*", "/dl/*"]
|
||||
run_worker_first = ["/api/*", "/dl/*", "/s/*"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||
Reference in New Issue
Block a user