feat: refine share flow and download handling
This commit is contained in:
1 parent
f8b727c3f6
commit
2f3a5daafc
16 files changed
+218
-319
No files matched your search
+1
-1
@@ -51,7 +51,7 @@ an appointed representative at an online or offline event.
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project
|
||||
maintainer at [hi@xi-xu.me](mailto:hi@xi-xu.me). All complaints will be reviewed and investigated
|
||||
maintainer at [i@xi-xu.me](mailto:i@xi-xu.me). All complaints will be reviewed and investigated
|
||||
promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any
|
||||
|
||||
+1
-10
@@ -1,19 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Xdrop is pre-`1.0`, so security fixes are only guaranteed on the latest development line.
|
||||
|
||||
| Version or branch | Supported |
|
||||
| ----------------------------------------- | --------- |
|
||||
| `main` | Yes |
|
||||
| older commits, branches, and ad-hoc forks | No |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security issues in public GitHub issues, discussions, or pull requests.
|
||||
|
||||
Instead, email [hi@xi-xu.me](mailto:hi@xi-xu.me) with the subject line `Xdrop security report`.
|
||||
Instead, email [i@xi-xu.me](mailto:i@xi-xu.me) with the subject line `Xdrop security report`.
|
||||
|
||||
Include as much of the following as you can:
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Please share enough context for someone else to help:
|
||||
Do not file public issues for vulnerabilities, exposed secrets, or anything that could put users at
|
||||
risk.
|
||||
|
||||
Follow [SECURITY.md](SECURITY.md) and email [hi@xi-xu.me](mailto:hi@xi-xu.me) instead.
|
||||
Follow [SECURITY.md](SECURITY.md) and email [i@xi-xu.me](mailto:i@xi-xu.me) instead.
|
||||
|
||||
## Before you open an issue
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function NotFoundPage() {
|
||||
<h2>This page was not found.</h2>
|
||||
<p className="muted page-intro">
|
||||
The address does not map to a page in Xdrop. If this came from a shared transfer, ask
|
||||
for the full URL, including the <code>#k=...</code> decryption fragment.
|
||||
the sender to resend the complete share details.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -47,8 +47,8 @@ export function NotFoundPage() {
|
||||
<div className="file-stack">
|
||||
<strong>Check the route family</strong>
|
||||
<p className="muted">
|
||||
Shared downloads live under <code>/t/:transferId</code>. Sender history lives under{' '}
|
||||
<code>/transfers</code>.
|
||||
Shared downloads and sender history use different pages. Return to a known page and
|
||||
open the transfer again from there.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -4,12 +4,14 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { usePageMetadataMock, useTransfersMock } = vi.hoisted(() => ({
|
||||
usePageMetadataMock: vi.fn(),
|
||||
extendTransferMock: vi.fn(async () => {}),
|
||||
useTransfersMock: vi.fn(() => ({
|
||||
transfers: [
|
||||
{
|
||||
id: 't1',
|
||||
},
|
||||
],
|
||||
extendTransfer: vi.fn(async () => {}),
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -46,9 +48,16 @@ describe('SharePage', () => {
|
||||
expect(usePageMetadataMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exposeUrl: false,
|
||||
title: 'Share the Full Link | Xdrop',
|
||||
title: 'Share This Transfer | Xdrop',
|
||||
}),
|
||||
)
|
||||
expect(screen.getByText('Share t1')).toBeInTheDocument()
|
||||
expect(shareCardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
transfer: { id: 't1' },
|
||||
onExtendTransfer: expect.any(Function),
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { usePageMetadata } from '@/lib/seo/usePageMetadata'
|
||||
/** SharePage shows sender-side progress and sharing controls for one local transfer. */
|
||||
export function SharePage() {
|
||||
const { transferId } = useParams()
|
||||
const { transfers } = useTransfers()
|
||||
const { transfers, extendTransfer } = useTransfers()
|
||||
const transfer = transfers.find((item) => item.id === transferId)
|
||||
|
||||
usePageMetadata({
|
||||
@@ -18,5 +18,5 @@ export function SharePage() {
|
||||
exposeUrl: false,
|
||||
})
|
||||
|
||||
return <ShareCard transfer={transfer} />
|
||||
return <ShareCard transfer={transfer} onExtendTransfer={extendTransfer} />
|
||||
}
|
||||
@@ -4,17 +4,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LocalTransferRecord } from '@/lib/indexeddb/db'
|
||||
|
||||
const { deleteTransferMock, extendTransferMock, transfersState } = vi.hoisted(() => ({
|
||||
const { deleteTransferMock, transfersState } = vi.hoisted(() => ({
|
||||
transfersState: [] as LocalTransferRecord[],
|
||||
deleteTransferMock: vi.fn(async () => {}),
|
||||
extendTransferMock: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/upload/TransferContext', () => ({
|
||||
useTransfers: () => ({
|
||||
transfers: transfersState,
|
||||
deleteTransfer: deleteTransferMock,
|
||||
extendTransfer: extendTransferMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -54,12 +52,6 @@ describe('HistoryBoard', () => {
|
||||
vi.setSystemTime(new Date('2026-03-20T10:00:00.000Z'))
|
||||
transfersState.splice(0, transfersState.length)
|
||||
deleteTransferMock.mockClear()
|
||||
extendTransferMock.mockClear()
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(async () => {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -95,9 +87,8 @@ describe('HistoryBoard', () => {
|
||||
expect(expiredCard).not.toBeNull()
|
||||
expect(screen.getByText('Expired transfer')).toBeInTheDocument()
|
||||
expect(
|
||||
within(expiredCard as HTMLElement).queryByRole('button', { name: /Copy link/i }),
|
||||
screen.queryByRole('button', { name: /Set expiry to 1 week from now/i }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Restore for 1 week/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the empty state when only expired transfers exist', () => {
|
||||
@@ -118,7 +109,7 @@ describe('HistoryBoard', () => {
|
||||
expect(screen.getByText('No transfers on this device')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies links, shows progress, and renders local warnings for active transfers', async () => {
|
||||
it('shows progress and renders local warnings for active transfers', () => {
|
||||
transfersState.splice(
|
||||
0,
|
||||
transfersState.length,
|
||||
@@ -146,25 +137,6 @@ describe('HistoryBoard', () => {
|
||||
expect(
|
||||
within(uploadingCard as HTMLElement).getByRole('link', { name: 'Open share page' }),
|
||||
).toHaveAttribute('href', '/share/uploading-1')
|
||||
|
||||
fireEvent.click(within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copy link' }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('https://example.com/t/uploading-1')
|
||||
expect(
|
||||
within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copied' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1800)
|
||||
})
|
||||
|
||||
expect(
|
||||
within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copy link' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces async action failures and clears stale errors before retrying', async () => {
|
||||
@@ -173,33 +145,11 @@ describe('HistoryBoard', () => {
|
||||
transfersState.length,
|
||||
createTransfer('ready-1', 'Ready transfer', '2026-03-21T10:00:00.000Z'),
|
||||
)
|
||||
extendTransferMock.mockRejectedValueOnce(new Error('Extend failed'))
|
||||
deleteTransferMock.mockRejectedValueOnce('unexpected')
|
||||
|
||||
renderBoard()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Extend failed')).toBeInTheDocument()
|
||||
expect(extendTransferMock).toHaveBeenCalledWith('ready-1', expect.any(Number))
|
||||
|
||||
extendTransferMock.mockResolvedValueOnce(undefined)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Extend failed')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(
|
||||
screen.getByText(/Confirm delete to remove this transfer from this device/i),
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' }))
|
||||
|
||||
await act(async () => {
|
||||
@@ -209,31 +159,4 @@ describe('HistoryBoard', () => {
|
||||
expect(screen.getByText('Could not update this transfer right now.')).toBeInTheDocument()
|
||||
expect(deleteTransferMock).toHaveBeenCalledWith('ready-1')
|
||||
})
|
||||
|
||||
it('shows a scoped fallback when clipboard access is unavailable', async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(async () => {
|
||||
throw new Error('denied')
|
||||
}),
|
||||
},
|
||||
})
|
||||
transfersState.splice(
|
||||
0,
|
||||
transfersState.length,
|
||||
createTransfer('ready-2', 'Ready transfer', '2026-03-21T10:00:00.000Z'),
|
||||
)
|
||||
|
||||
renderBoard()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText(/Open the share page to copy the full link manually/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { MAX_EXPIRY_SECONDS, getExpiryOptionLabel } from '@xdrop/shared'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -14,8 +13,7 @@ import { isExpiredTransfer } from '@/lib/transfers/expiry'
|
||||
|
||||
/** HistoryBoard is the sender-side dashboard for transfer status, copy, extend, and delete actions. */
|
||||
export function HistoryBoard() {
|
||||
const { transfers, deleteTransfer, extendTransfer } = useTransfers()
|
||||
const [copiedTransferId, setCopiedTransferId] = useState<string>()
|
||||
const { transfers, deleteTransfer } = useTransfers()
|
||||
const [expandedExpired, setExpandedExpired] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [actionErrors, setActionErrors] = useState<Record<string, string>>({})
|
||||
@@ -63,33 +61,6 @@ export function HistoryBoard() {
|
||||
})
|
||||
}
|
||||
|
||||
/** copyLink keeps link-copy feedback local to the transfer the user interacted with. */
|
||||
const copyLink = async (transferId: string, shareUrl: string | undefined) => {
|
||||
if (!shareUrl) {
|
||||
return
|
||||
}
|
||||
clearActionError(transferId)
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
setCopiedTransferId(transferId)
|
||||
window.setTimeout(() => setCopiedTransferId(undefined), 1800)
|
||||
} catch {
|
||||
setActionErrors((current) => ({
|
||||
...current,
|
||||
[transferId]: 'Copy failed. Open the share page to copy the full link manually.',
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtend = async (transferId: string) => {
|
||||
clearActionError(transferId)
|
||||
try {
|
||||
await extendTransfer(transferId, MAX_EXPIRY_SECONDS)
|
||||
} catch (error) {
|
||||
setActionError(transferId, error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (transferId: string) => {
|
||||
clearActionError(transferId)
|
||||
try {
|
||||
@@ -187,21 +158,11 @@ export function HistoryBoard() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="history-actions">
|
||||
{transfer.shareUrl && !expired ? (
|
||||
<Button tone="ghost" onClick={() => void copyLink(transfer.id, transfer.shareUrl)}>
|
||||
{copiedTransferId === transfer.id ? 'Copied' : 'Copy link'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!expired ? (
|
||||
<Link className="button button--ghost" to={`/share/${transfer.id}`}>
|
||||
Open share page
|
||||
</Link>
|
||||
) : null}
|
||||
{canManageTransfer && !expired ? (
|
||||
<Button tone="ghost" onClick={() => void handleExtend(transfer.id)}>
|
||||
{`Set expiry to ${getExpiryOptionLabel(MAX_EXPIRY_SECONDS)} from now`}
|
||||
</Button>
|
||||
) : null}
|
||||
{pendingDeleteTransferId === transfer.id ? (
|
||||
<>
|
||||
<Button tone="danger" onClick={() => void handleDelete(transfer.id)}>
|
||||
@@ -232,11 +193,9 @@ export function HistoryBoard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingDeleteTransferId === transfer.id ? (
|
||||
{pendingDeleteTransferId === transfer.id && !canManageTransfer ? (
|
||||
<p aria-live="polite" className="warning">
|
||||
{canManageTransfer
|
||||
? 'Confirm delete to remove this transfer from this device.'
|
||||
: 'Confirm forget to remove this local record from this device.'}
|
||||
Confirm forget to remove this local record from this device.
|
||||
</p>
|
||||
) : null}
|
||||
{transfer.localManagementCleared ? (
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
} from '@/lib/crypto/envelope'
|
||||
import { parseLinkKey } from '@/lib/crypto/urlKey'
|
||||
import { createDecryptedReadableStream, decryptFileToBlob } from '@/lib/download/decrypt'
|
||||
import { buildZipFromStreams, writeZipStream } from '@/lib/download/zip'
|
||||
import { buildZipFromStreams } from '@/lib/download/zip'
|
||||
import { formatBytes } from '@/lib/files/formatBytes'
|
||||
import { safeDownloadName, sanitizePath } from '@/lib/files/paths'
|
||||
import { isAbortError, openSaveWritable, saveBlob, supportsStreamingSave } from '@/lib/files/save'
|
||||
import { saveBlob } from '@/lib/files/save'
|
||||
import { formatLocalDateTime } from '@/lib/i18n/formatDateTime'
|
||||
import { PRIVATE_ROBOTS, RECEIVE_PAGE_DESCRIPTION, RECEIVE_PAGE_TITLE } from '@/lib/seo/site'
|
||||
import { usePageMetadata } from '@/lib/seo/usePageMetadata'
|
||||
@@ -94,7 +94,7 @@ export function ReceiveTransfer({ transferId }: Props) {
|
||||
})()
|
||||
}, [fragmentKey, transferId])
|
||||
|
||||
/** downloadFile streams to disk when possible and falls back to an in-memory Blob otherwise. */
|
||||
/** downloadFile decrypts to a Blob and hands it to the browser download manager. */
|
||||
const downloadFile = async (file: ManifestFileEntry) => {
|
||||
if (!rootKey) {
|
||||
return
|
||||
@@ -115,34 +115,18 @@ export function ReceiveTransfer({ transferId }: Props) {
|
||||
setDownloadProgress(Math.round((completedBytes / Math.max(totalBytes, 1)) * 100)),
|
||||
}
|
||||
|
||||
if (supportsStreamingSave()) {
|
||||
const writable = await openSaveWritable(filename, {
|
||||
mimeType: file.mimeType || 'application/octet-stream',
|
||||
})
|
||||
if (writable) {
|
||||
await createDecryptedReadableStream(downloadArgs).pipeTo(writable)
|
||||
} else {
|
||||
const blob = await decryptFileToBlob(downloadArgs)
|
||||
saveBlob(blob, filename)
|
||||
}
|
||||
} else {
|
||||
const blob = await decryptFileToBlob(downloadArgs)
|
||||
saveBlob(blob, filename)
|
||||
}
|
||||
|
||||
setDownloadProgress(100)
|
||||
} catch (caughtError) {
|
||||
if (isAbortError(caughtError)) {
|
||||
setDownloadProgress(0)
|
||||
return
|
||||
}
|
||||
setDownloadError(caughtError instanceof Error ? caughtError.message : 'Download failed.')
|
||||
} finally {
|
||||
setActiveDownload((current) => (current === file.fileId ? null : current))
|
||||
}
|
||||
}
|
||||
|
||||
/** downloadAll streams ZIP creation to disk when possible and avoids per-file buffering. */
|
||||
/** downloadAll creates one ZIP Blob and hands it to the browser download manager. */
|
||||
const downloadAll = async () => {
|
||||
if (!manifest || !rootKey) {
|
||||
return
|
||||
@@ -176,25 +160,11 @@ export function ReceiveTransfer({ transferId }: Props) {
|
||||
},
|
||||
}))
|
||||
|
||||
if (supportsStreamingSave()) {
|
||||
const writable = await openSaveWritable(zipName, { mimeType: 'application/zip' })
|
||||
if (writable) {
|
||||
await writeZipStream(createZipEntries(), writable)
|
||||
} else {
|
||||
const zip = await buildZipFromStreams(createZipEntries())
|
||||
saveBlob(zip, zipName)
|
||||
}
|
||||
} else {
|
||||
const zip = await buildZipFromStreams(createZipEntries())
|
||||
saveBlob(zip, zipName)
|
||||
}
|
||||
|
||||
setDownloadProgress(100)
|
||||
} catch (caughtError) {
|
||||
if (isAbortError(caughtError)) {
|
||||
setDownloadProgress(0)
|
||||
return
|
||||
}
|
||||
setDownloadError(caughtError instanceof Error ? caughtError.message : 'Download failed.')
|
||||
} finally {
|
||||
setActiveDownload((current) => (current === 'zip' ? null : current))
|
||||
|
||||
@@ -45,10 +45,13 @@ function createTransfer(
|
||||
}
|
||||
|
||||
describe('ShareCard', () => {
|
||||
const extendTransferMock = vi.fn(async () => {})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-20T10:00:00.000Z'))
|
||||
toDataUrlMock.mockClear()
|
||||
extendTransferMock.mockClear()
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(async () => {}),
|
||||
@@ -66,6 +69,7 @@ describe('ShareCard', () => {
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-1', 'Active transfer', '2026-03-21T10:00:00.000Z')}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
@@ -74,10 +78,15 @@ describe('ShareCard', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByRole('heading', { name: /Share the full link/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /Share this transfer/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Copy link/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Share link/i })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://example.com/t/active-1#k=test')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Set expiry to 1 week from now/i }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByDisplayValue('https://example.com/t/active-1#k=test'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByAltText('Transfer QR code')).toBeInTheDocument()
|
||||
expect(toDataUrlMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -119,6 +128,7 @@ describe('ShareCard', () => {
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-2', 'Copied transfer', '2026-03-21T10:00:00.000Z')}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
@@ -153,6 +163,7 @@ describe('ShareCard', () => {
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-3', 'Shared transfer', '2026-03-21T10:00:00.000Z')}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
@@ -180,6 +191,7 @@ describe('ShareCard', () => {
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-4', 'Fallback transfer', '2026-03-21T10:00:00.000Z')}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
@@ -200,7 +212,7 @@ describe('ShareCard', () => {
|
||||
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a manual-copy fallback when clipboard access fails', async () => {
|
||||
it('shows a retry-focused fallback when clipboard access fails', async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(async () => {
|
||||
@@ -213,6 +225,7 @@ describe('ShareCard', () => {
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-6', 'Clipboard fallback', '2026-03-21T10:00:00.000Z')}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
@@ -227,8 +240,12 @@ describe('ShareCard', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Select the full link below to copy it manually/i)).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://example.com/t/active-6#k=test')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/Use your browser share tools or try again on this device/i),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByDisplayValue('https://example.com/t/active-6#k=test'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores canceled share-sheet requests without surfacing an error', async () => {
|
||||
@@ -241,6 +258,7 @@ describe('ShareCard', () => {
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-7', 'Dismissed share', '2026-03-21T10:00:00.000Z')}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
@@ -302,7 +320,7 @@ describe('ShareCard', () => {
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<ShareCard transfer={pausedTransfer} />
|
||||
<ShareCard transfer={pausedTransfer} onExtendTransfer={extendTransferMock} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
@@ -320,7 +338,7 @@ describe('ShareCard', () => {
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ShareCard transfer={failedTransfer} />
|
||||
<ShareCard transfer={failedTransfer} onExtendTransfer={extendTransferMock} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
await act(async () => {
|
||||
@@ -330,7 +348,7 @@ describe('ShareCard', () => {
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ShareCard transfer={deletedTransfer} />
|
||||
<ShareCard transfer={deletedTransfer} onExtendTransfer={extendTransferMock} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
await act(async () => {
|
||||
@@ -341,7 +359,7 @@ describe('ShareCard', () => {
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ShareCard transfer={uploadingTransfer} />
|
||||
<ShareCard transfer={uploadingTransfer} onExtendTransfer={extendTransferMock} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
await act(async () => {
|
||||
@@ -350,6 +368,70 @@ describe('ShareCard', () => {
|
||||
expect(screen.getByText('The link will work once the upload finishes.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('extends expiry from the share page and surfaces failures locally', async () => {
|
||||
extendTransferMock.mockRejectedValueOnce(new Error('Extend failed'))
|
||||
|
||||
const transfer = createTransfer('active-8', 'Extend transfer', '2026-03-21T10:00:00.000Z')
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<ShareCard transfer={transfer} onExtendTransfer={extendTransferMock} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(extendTransferMock).toHaveBeenCalledWith('active-8', 7 * 24 * 60 * 60)
|
||||
expect(screen.getByText('Extend failed')).toBeInTheDocument()
|
||||
|
||||
extendTransferMock.mockResolvedValueOnce(undefined)
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ShareCard transfer={transfer} onExtendTransfer={extendTransferMock} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Extend failed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the extend action when local management is unavailable', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-9', 'No manage token', '2026-03-21T10:00:00.000Z', {
|
||||
manageToken: '',
|
||||
})}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Set expiry to 1 week from now/i }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the QR placeholder when QR generation fails', async () => {
|
||||
toDataUrlMock.mockRejectedValueOnce(new Error('QR failed'))
|
||||
|
||||
@@ -357,6 +439,7 @@ describe('ShareCard', () => {
|
||||
<MemoryRouter>
|
||||
<ShareCard
|
||||
transfer={createTransfer('active-5', 'Broken QR transfer', '2026-03-21T10:00:00.000Z')}
|
||||
onExtendTransfer={extendTransferMock}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_EXPIRY_SECONDS, getExpiryOptionLabel } from '@xdrop/shared'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import QRCode from 'qrcode'
|
||||
@@ -14,17 +15,25 @@ import { isExpiredTransfer } from '@/lib/transfers/expiry'
|
||||
|
||||
type Props = {
|
||||
transfer: LocalTransferRecord | undefined
|
||||
onExtendTransfer?: (transferId: string, expiresInSeconds: number) => Promise<void>
|
||||
}
|
||||
|
||||
/** ShareCard shows sender-side sharing controls and current upload state for one transfer. */
|
||||
export function ShareCard({ transfer }: Props) {
|
||||
export function ShareCard({ transfer, onExtendTransfer }: Props) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [qrState, setQrState] = useState<{ dataUrl?: string; shareUrl?: string }>({})
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle')
|
||||
const [isExtending, setIsExtending] = useState(false)
|
||||
const [actionError, setActionError] = useState<string>()
|
||||
const progress = transfer ? (transfer.uploadedBytes / Math.max(transfer.totalBytes, 1)) * 100 : 0
|
||||
const expired = transfer ? isExpiredTransfer(transfer, now) : false
|
||||
const canShareLink = Boolean(transfer?.shareUrl) && !expired && transfer?.status !== 'deleted'
|
||||
const canExtendTransfer =
|
||||
Boolean(transfer?.manageToken) &&
|
||||
!expired &&
|
||||
transfer?.status !== 'deleted' &&
|
||||
transfer?.localManagementCleared !== true &&
|
||||
onExtendTransfer !== undefined
|
||||
const qrShareUrl = canShareLink ? transfer?.shareUrl : undefined
|
||||
const qrDataUrl = qrShareUrl && qrState.shareUrl === qrShareUrl ? qrState.dataUrl : undefined
|
||||
|
||||
@@ -80,7 +89,7 @@ export function ShareCard({ transfer }: Props) {
|
||||
window.setTimeout(() => setCopyState('idle'), 1800)
|
||||
} catch {
|
||||
setCopyState('idle')
|
||||
setActionError('Copy failed. Select the full link below to copy it manually.')
|
||||
setActionError('Copy failed. Use your browser share tools or try again on this device.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +119,24 @@ export function ShareCard({ transfer }: Props) {
|
||||
await copyLink()
|
||||
}
|
||||
|
||||
const extendExpiry = async () => {
|
||||
if (!transfer || !canExtendTransfer || !onExtendTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setActionError(undefined)
|
||||
setIsExtending(true)
|
||||
await onExtendTransfer(transfer.id, MAX_EXPIRY_SECONDS)
|
||||
} catch (error) {
|
||||
setActionError(
|
||||
error instanceof Error ? error.message : 'Could not update this transfer right now.',
|
||||
)
|
||||
} finally {
|
||||
setIsExtending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!transfer) {
|
||||
return (
|
||||
<PageStateCard
|
||||
@@ -173,7 +200,7 @@ export function ShareCard({ transfer }: Props) {
|
||||
<div className="page-hero-head">
|
||||
<div className="page-hero-copy">
|
||||
<p className="eyebrow">Share</p>
|
||||
<h2>Share the full link.</h2>
|
||||
<h2>Share this transfer.</h2>
|
||||
<p className="page-summary-name">{transfer.displayName}</p>
|
||||
<p className="muted page-intro">
|
||||
The server stores ciphertext and operational metadata, not plaintext file names, file
|
||||
@@ -208,23 +235,17 @@ export function ShareCard({ transfer }: Props) {
|
||||
<Button onClick={() => void shareLink()} tone="ghost" disabled={!canShareLink}>
|
||||
Share link
|
||||
</Button>
|
||||
{canExtendTransfer ? (
|
||||
<Button tone="ghost" onClick={() => void extendExpiry()} disabled={isExtending}>
|
||||
{isExtending
|
||||
? 'Updating expiry…'
|
||||
: `Set expiry to ${getExpiryOptionLabel(MAX_EXPIRY_SECONDS)} from now`}
|
||||
</Button>
|
||||
) : null}
|
||||
<Link className="button button--ghost" to="/transfers">
|
||||
Manage transfers
|
||||
</Link>
|
||||
</div>
|
||||
{transfer.shareUrl ? (
|
||||
<label className="field">
|
||||
<span>Full link</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
name="shareUrl"
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={transfer.shareUrl}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<p aria-live="polite" className="warning">
|
||||
{actionError}
|
||||
@@ -232,13 +253,13 @@ export function ShareCard({ transfer }: Props) {
|
||||
) : null}
|
||||
{transfer.localManagementCleared ? (
|
||||
<p className="warning">
|
||||
Privacy mode removed local transfer controls after upload. Keep the share link safe.
|
||||
Extend and delete are no longer available here.
|
||||
Privacy mode removed local transfer controls after upload. Keep your saved share access
|
||||
safe. Extend and delete are no longer available here.
|
||||
</p>
|
||||
) : null}
|
||||
<p className="warning">
|
||||
Keep the full link, including the <code>#k=</code> part. Without it, the files cannot be
|
||||
decrypted.
|
||||
Keep the original share details intact. If the decryption key is missing, the files cannot
|
||||
be opened.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { saveBlob } from './save'
|
||||
|
||||
describe('saveBlob', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses the browser download manager instead of a file picker', () => {
|
||||
const createObjectURLSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:https://example.com/download')
|
||||
const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const setTimeoutSpy = vi.spyOn(window, 'setTimeout')
|
||||
const clickSpy = vi.fn()
|
||||
const anchor = document.createElement('a')
|
||||
anchor.click = clickSpy
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
|
||||
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||
if (tagName.toLowerCase() === 'a') {
|
||||
return anchor
|
||||
}
|
||||
|
||||
return originalCreateElement(tagName)
|
||||
})
|
||||
|
||||
const blob = new Blob(['hello'], { type: 'text/plain' })
|
||||
saveBlob(blob, 'hello.txt')
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(anchor.download).toBe('hello.txt')
|
||||
expect(anchor.href).toBe('blob:https://example.com/download')
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledOnce()
|
||||
|
||||
const revokeCallback = setTimeoutSpy.mock.calls[0]?.[0]
|
||||
expect(revokeCallback).toBeTypeOf('function')
|
||||
;(revokeCallback as () => void)()
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:https://example.com/download')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,114 +1,16 @@
|
||||
/**
|
||||
* Download helpers that use the File System Access API when available and anchor downloads otherwise.
|
||||
* Download helpers that hand files to the browser's normal download manager.
|
||||
*/
|
||||
|
||||
type FilePickerAcceptTypeLike = {
|
||||
description?: string
|
||||
accept: Record<string, string[]>
|
||||
}
|
||||
|
||||
type FileSystemWriteChunk =
|
||||
| BufferSource
|
||||
| Blob
|
||||
| string
|
||||
| {
|
||||
type: 'write'
|
||||
position?: number
|
||||
data: BufferSource | Blob | string
|
||||
}
|
||||
| {
|
||||
type: 'seek'
|
||||
position: number
|
||||
}
|
||||
| {
|
||||
type: 'truncate'
|
||||
size: number
|
||||
}
|
||||
|
||||
export type FileSystemWritableLike = WritableStream<Uint8Array> & {
|
||||
write: (data: FileSystemWriteChunk) => Promise<void>
|
||||
close: () => Promise<void>
|
||||
abort: (reason?: unknown) => Promise<void>
|
||||
}
|
||||
|
||||
type FileSystemFileHandleLike = {
|
||||
createWritable: (options?: { keepExistingData?: boolean }) => Promise<FileSystemWritableLike>
|
||||
}
|
||||
|
||||
type WindowWithSavePicker = Window & {
|
||||
showSaveFilePicker?: (options?: {
|
||||
suggestedName?: string
|
||||
types?: FilePickerAcceptTypeLike[]
|
||||
excludeAcceptAllOption?: boolean
|
||||
}) => Promise<FileSystemFileHandleLike>
|
||||
}
|
||||
|
||||
/** supportsStreamingSave detects whether the browser can stream directly to a picked file. */
|
||||
export function supportsStreamingSave() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
navigator.webdriver !== true &&
|
||||
typeof (window as WindowWithSavePicker).showSaveFilePicker === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
/** openSaveWritable opens a writable file handle for streaming downloads when supported. */
|
||||
export async function openSaveWritable(suggestedName: string, options?: { mimeType?: string }) {
|
||||
const picker = (window as WindowWithSavePicker).showSaveFilePicker
|
||||
if (!picker) {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = buildPickerType(suggestedName, options?.mimeType)
|
||||
const handle = await picker(
|
||||
type
|
||||
? {
|
||||
suggestedName,
|
||||
types: [type],
|
||||
}
|
||||
: { suggestedName },
|
||||
)
|
||||
|
||||
return handle.createWritable({ keepExistingData: false })
|
||||
}
|
||||
|
||||
/** saveBlob falls back to a standard browser download using an object URL. */
|
||||
/** saveBlob triggers a standard browser download using an object URL. */
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.rel = 'noopener'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** isAbortError normalizes user-cancelled file picker errors for callers. */
|
||||
export function isAbortError(error: unknown) {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
function buildPickerType(
|
||||
suggestedName: string,
|
||||
mimeType?: string,
|
||||
): FilePickerAcceptTypeLike | undefined {
|
||||
const extension = extensionFromName(suggestedName)
|
||||
if (!mimeType || !extension) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
description: mimeType,
|
||||
accept: {
|
||||
[mimeType]: [extension],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFromName(filename: string) {
|
||||
const dot = filename.lastIndexOf('.')
|
||||
if (dot <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return filename.slice(dot)
|
||||
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
@@ -15,15 +15,15 @@ export const DEFAULT_SEO_DESCRIPTION = PROJECT_ONE_LINER
|
||||
export const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop'
|
||||
export const HISTORY_PAGE_DESCRIPTION =
|
||||
'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.'
|
||||
export const SHARE_PAGE_TITLE = 'Share the Full Link | Xdrop'
|
||||
export const SHARE_PAGE_TITLE = 'Share This Transfer | Xdrop'
|
||||
export const SHARE_PAGE_DESCRIPTION =
|
||||
'Review upload status and copy the full share link for a transfer staged in this browser on this device.'
|
||||
'Review upload status and share a transfer staged in this browser on this device without exposing its address on the page.'
|
||||
export const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop'
|
||||
export const RECEIVE_PAGE_DESCRIPTION =
|
||||
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.'
|
||||
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the browser and never reaches the server.'
|
||||
export const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop'
|
||||
export const NOT_FOUND_PAGE_DESCRIPTION =
|
||||
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.'
|
||||
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask the sender to resend the complete share details.'
|
||||
export const DEFAULT_OG_IMAGE_PATH = '/brand-lockup-horizontal.png'
|
||||
export const DEFAULT_OG_IMAGE_ALT = 'Xdrop horizontal brand lockup'
|
||||
export const DEFAULT_LOGO_PATH = '/brand-symbol-512.png'
|
||||
|
||||
@@ -20,8 +20,6 @@ Environment requirements:
|
||||
|
||||
## Upload
|
||||
|
||||
Run from the skill root:
|
||||
|
||||
```bash
|
||||
bun scripts/upload.mjs --server <xdrop-site-url> <file-or-directory> [...]
|
||||
```
|
||||
@@ -49,8 +47,6 @@ If the user wants verification, upload a small temporary file and then confirm t
|
||||
|
||||
Require the full share link, including `#k=...`. Without the fragment key, the transfer cannot be decrypted.
|
||||
|
||||
Run from the skill root:
|
||||
|
||||
```bash
|
||||
bun scripts/download.mjs "<share-url>"
|
||||
```
|
||||
|
||||
@@ -212,7 +212,7 @@ async function createSingleFileTransfer(
|
||||
})
|
||||
await page.getByRole('button', { name: 'Start transfer' }).click()
|
||||
await expect(page).toHaveURL(/\/share\//, { timeout: 60_000 })
|
||||
await expect(page.getByRole('heading', { name: 'Share the full link.' })).toBeVisible({
|
||||
await expect(page.getByRole('heading', { name: 'Share this transfer.' })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
})
|
||||
await expect(page.locator('.status-badge').filter({ hasText: 'Ready' })).toBeVisible({
|
||||
@@ -254,7 +254,7 @@ async function createFolderTransfer(
|
||||
}, options.files)
|
||||
await page.getByRole('button', { name: 'Start transfer' }).click()
|
||||
await expect(page).toHaveURL(/\/share\//, { timeout: 60_000 })
|
||||
await expect(page.getByRole('heading', { name: 'Share the full link.' })).toBeVisible({
|
||||
await expect(page.getByRole('heading', { name: 'Share this transfer.' })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
})
|
||||
await expect(page.locator('.status-badge').filter({ hasText: 'Ready' })).toBeVisible({
|
||||
|
||||
Reference in new issue
Block a user