diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3eebb00..32b7161 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 3b8df17..548d233 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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: diff --git a/SUPPORT.md b/SUPPORT.md index 435404d..71f54ef 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -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 diff --git a/apps/web/src/app/routes/NotFoundPage.tsx b/apps/web/src/app/routes/NotFoundPage.tsx index 8c4084f..6aa6319 100644 --- a/apps/web/src/app/routes/NotFoundPage.tsx +++ b/apps/web/src/app/routes/NotFoundPage.tsx @@ -19,7 +19,7 @@ export function NotFoundPage() {

This page was not found.

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 sender to resend the complete share details.

@@ -47,8 +47,8 @@ export function NotFoundPage() {
Check the route family

- Shared downloads live under /t/:transferId. Sender history lives under{' '} - /transfers. + Shared downloads and sender history use different pages. Return to a known page and + open the transfer again from there.

diff --git a/apps/web/src/app/routes/SharePage.test.tsx b/apps/web/src/app/routes/SharePage.test.tsx index 466d2e1..eec4a32 100644 --- a/apps/web/src/app/routes/SharePage.test.tsx +++ b/apps/web/src/app/routes/SharePage.test.tsx @@ -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, + ) }) }) diff --git a/apps/web/src/app/routes/SharePage.tsx b/apps/web/src/app/routes/SharePage.tsx index 85ebbfd..66d2b2a 100644 --- a/apps/web/src/app/routes/SharePage.tsx +++ b/apps/web/src/app/routes/SharePage.tsx @@ -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 + return } diff --git a/apps/web/src/features/history/HistoryBoard.test.tsx b/apps/web/src/features/history/HistoryBoard.test.tsx index 0d148b1..5cf3755 100644 --- a/apps/web/src/features/history/HistoryBoard.test.tsx +++ b/apps/web/src/features/history/HistoryBoard.test.tsx @@ -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() - }) }) diff --git a/apps/web/src/features/history/HistoryBoard.tsx b/apps/web/src/features/history/HistoryBoard.tsx index 159d9ba..c5ef1bb 100644 --- a/apps/web/src/features/history/HistoryBoard.tsx +++ b/apps/web/src/features/history/HistoryBoard.tsx @@ -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() + const { transfers, deleteTransfer } = useTransfers() const [expandedExpired, setExpandedExpired] = useState(false) const [now, setNow] = useState(() => Date.now()) const [actionErrors, setActionErrors] = useState>({}) @@ -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}
- {transfer.shareUrl && !expired ? ( - - ) : null} {!expired ? ( Open share page ) : null} - {canManageTransfer && !expired ? ( - - ) : null} {pendingDeleteTransferId === transfer.id ? ( <>
- {pendingDeleteTransferId === transfer.id ? ( + {pendingDeleteTransferId === transfer.id && !canManageTransfer ? (

- {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.

) : null} {transfer.localManagementCleared ? ( diff --git a/apps/web/src/features/receive/ReceiveTransfer.tsx b/apps/web/src/features/receive/ReceiveTransfer.tsx index 30f677d..2676505 100644 --- a/apps/web/src/features/receive/ReceiveTransfer.tsx +++ b/apps/web/src/features/receive/ReceiveTransfer.tsx @@ -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) - } + 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) - } + 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)) diff --git a/apps/web/src/features/share/ShareCard.test.tsx b/apps/web/src/features/share/ShareCard.test.tsx index a9f5d52..1f4aaf6 100644 --- a/apps/web/src/features/share/ShareCard.test.tsx +++ b/apps/web/src/features/share/ShareCard.test.tsx @@ -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', () => { , ) @@ -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', () => { , ) @@ -153,6 +163,7 @@ describe('ShareCard', () => { , ) @@ -180,6 +191,7 @@ describe('ShareCard', () => { , ) @@ -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', () => { , ) @@ -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', () => { , ) @@ -302,7 +320,7 @@ describe('ShareCard', () => { const { rerender } = render( - + , ) @@ -320,7 +338,7 @@ describe('ShareCard', () => { rerender( - + , ) await act(async () => { @@ -330,7 +348,7 @@ describe('ShareCard', () => { rerender( - + , ) await act(async () => { @@ -341,7 +359,7 @@ describe('ShareCard', () => { rerender( - + , ) 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( + + + , + ) + + 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( + + + , + ) + + 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( + + + , + ) + + 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', () => { , ) diff --git a/apps/web/src/features/share/ShareCard.tsx b/apps/web/src/features/share/ShareCard.tsx index b75f232..98ffe77 100644 --- a/apps/web/src/features/share/ShareCard.tsx +++ b/apps/web/src/features/share/ShareCard.tsx @@ -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 } /** 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() 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 (

Share

-

Share the full link.

+

Share this transfer.

{transfer.displayName}

The server stores ciphertext and operational metadata, not plaintext file names, file @@ -208,23 +235,17 @@ export function ShareCard({ transfer }: Props) { + {canExtendTransfer ? ( + + ) : null} Manage transfers

- {transfer.shareUrl ? ( - - ) : null} {actionError ? (

{actionError} @@ -232,13 +253,13 @@ export function ShareCard({ transfer }: Props) { ) : null} {transfer.localManagementCleared ? (

- 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.

) : null}

- Keep the full link, including the #k= 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.

diff --git a/apps/web/src/lib/files/save.test.ts b/apps/web/src/lib/files/save.test.ts new file mode 100644 index 0000000..d292dce --- /dev/null +++ b/apps/web/src/lib/files/save.test.ts @@ -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() + }) +}) diff --git a/apps/web/src/lib/files/save.ts b/apps/web/src/lib/files/save.ts index e16d051..4e01033 100644 --- a/apps/web/src/lib/files/save.ts +++ b/apps/web/src/lib/files/save.ts @@ -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 -} - -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 & { - write: (data: FileSystemWriteChunk) => Promise - close: () => Promise - abort: (reason?: unknown) => Promise -} - -type FileSystemFileHandleLike = { - createWritable: (options?: { keepExistingData?: boolean }) => Promise -} - -type WindowWithSavePicker = Window & { - showSaveFilePicker?: (options?: { - suggestedName?: string - types?: FilePickerAcceptTypeLike[] - excludeAcceptAllOption?: boolean - }) => Promise -} - -/** 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) } diff --git a/apps/web/src/lib/seo/site.ts b/apps/web/src/lib/seo/site.ts index b9f98a4..53cf3f8 100644 --- a/apps/web/src/lib/seo/site.ts +++ b/apps/web/src/lib/seo/site.ts @@ -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' diff --git a/skills/xdrop/SKILL.md b/skills/xdrop/SKILL.md index a89dcd1..67879eb 100644 --- a/skills/xdrop/SKILL.md +++ b/skills/xdrop/SKILL.md @@ -20,8 +20,6 @@ Environment requirements: ## Upload -Run from the skill root: - ```bash bun scripts/upload.mjs --server [...] ``` @@ -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 "" ``` diff --git a/tests/e2e/xdrop.spec.ts b/tests/e2e/xdrop.spec.ts index 1ec7916..5424b96 100644 --- a/tests/e2e/xdrop.spec.ts +++ b/tests/e2e/xdrop.spec.ts @@ -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({