mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-20 21:26:04 +00:00
* feat(web): ingest GET /share?url=&text=&title= deep links Native companions that cannot POST via Web Share Target can open the same session picker by synthesizing the IndexedDB transfer client-side. When id is present, the existing SW path still wins. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): preserve share deep-link whitespace; scrub content beside id Keep GET content strings verbatim when non-empty (match POST form-data). When id is present with leftover url/text/title, replace to ?id= only so payload does not linger in the address bar. Query-param contract stays — fragments would break shipped native companions. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): ingest /share deep links via URL fragment, not query Shared url/text/title must not appear on the HTTP request line — hub Hono logger (and any access log) records path+query. Native companions open /share#url=&text=&title=; the client reads the fragment, scrubs it, and continues with the existing ?id= picker path. Query validateSearch keeps only id/error (Web Share Target redirect). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep /share hash ingest across StrictMode remount Capture the fragment in useState and reuse a single putShareTransfer promise so the first effect's scrub + cleanup cancel does not lose the deep-link under React.StrictMode. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): fetch companion fileUrl into /share transfer files Native shares cannot put binaries in the hash fragment. Companions host a one-shot CORS URL and pass fileUrl/fileName/fileType; the share page fetches bytes into the same IndexedDB files[] as Web Share Target POST. * fix(web): cap share fileUrl fetch at the composer upload ceiling Stream fileUrl downloads with Content-Length and body size checks matching MAX_UPLOAD_BYTES so a crafted deep link cannot buffer unbounded bytes into IndexedDB. Align native deep-link docs on the fileUrl hand-off vs POST. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): cast streamed fileUrl chunks to BlobPart for tsc Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
buildSharePayloadFromDeepLink,
|
|
buildSharePayloadFromFormData,
|
|
buildSharePayloadFromSearchFields,
|
|
hasShareDeepLinkContent,
|
|
ingestShareRequest,
|
|
parseShareDeepLinkFields,
|
|
parseShareHash,
|
|
parseShareSearch,
|
|
type ShareTransferPayload,
|
|
} from './shareTransfer'
|
|
|
|
describe('buildSharePayloadFromFormData', () => {
|
|
it('extracts text-only share with empty file list', async () => {
|
|
const fd = new FormData()
|
|
fd.set('title', 'My note')
|
|
fd.set('text', 'Hello world')
|
|
fd.set('url', 'https://example.com/page')
|
|
|
|
const payload = await buildSharePayloadFromFormData(fd, 1700000000000)
|
|
|
|
expect(payload).toEqual({
|
|
title: 'My note',
|
|
text: 'Hello world',
|
|
url: 'https://example.com/page',
|
|
files: [],
|
|
createdAt: 1700000000000,
|
|
})
|
|
})
|
|
|
|
it('falls back to empty strings when fields are missing', async () => {
|
|
const fd = new FormData()
|
|
const payload = await buildSharePayloadFromFormData(fd, 42)
|
|
|
|
expect(payload.title).toBe('')
|
|
expect(payload.text).toBe('')
|
|
expect(payload.url).toBe('')
|
|
expect(payload.files).toEqual([])
|
|
expect(payload.createdAt).toBe(42)
|
|
})
|
|
|
|
it('extracts a single image file with type', async () => {
|
|
const fd = new FormData()
|
|
const file = new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' })
|
|
fd.append('files', file)
|
|
|
|
const payload = await buildSharePayloadFromFormData(fd)
|
|
|
|
expect(payload.files).toHaveLength(1)
|
|
expect(payload.files[0]).toMatchObject({
|
|
name: 'photo.png',
|
|
type: 'image/png',
|
|
})
|
|
expect(payload.files[0].blob).toBeInstanceOf(Blob)
|
|
})
|
|
|
|
it('handles multi-file shares preserving order', async () => {
|
|
const fd = new FormData()
|
|
const a = new File([new Uint8Array([1])], 'a.txt', { type: 'text/plain' })
|
|
const b = new File([new Uint8Array([2])], 'b.pdf', { type: 'application/pdf' })
|
|
const c = new File([new Uint8Array([3])], 'c.bin', { type: '' })
|
|
fd.append('files', a)
|
|
fd.append('files', b)
|
|
fd.append('files', c)
|
|
|
|
const payload = await buildSharePayloadFromFormData(fd)
|
|
|
|
expect(payload.files.map((f) => f.name)).toEqual(['a.txt', 'b.pdf', 'c.bin'])
|
|
// Empty mime should fall back to application/octet-stream so the
|
|
// downstream uploader doesn't choke on Content-Type: ''.
|
|
expect(payload.files[2].type).toBe('application/octet-stream')
|
|
})
|
|
|
|
it('ignores non-File entries under the "files" key', async () => {
|
|
const fd = new FormData()
|
|
fd.append('files', 'stringy not a file')
|
|
const file = new File([new Uint8Array([0])], 'real.txt', { type: 'text/plain' })
|
|
fd.append('files', file)
|
|
|
|
const payload = await buildSharePayloadFromFormData(fd)
|
|
|
|
expect(payload.files).toHaveLength(1)
|
|
expect(payload.files[0].name).toBe('real.txt')
|
|
})
|
|
})
|
|
|
|
describe('parseShareSearch', () => {
|
|
it('keeps id and error as today', () => {
|
|
expect(parseShareSearch({ id: 'xfer-1', error: 'ingest' })).toEqual({
|
|
id: 'xfer-1',
|
|
error: 'ingest',
|
|
})
|
|
})
|
|
|
|
it('ignores url/text/title in the query (content belongs in the fragment)', () => {
|
|
expect(parseShareSearch({
|
|
id: 'xfer-1',
|
|
url: 'https://example.com',
|
|
text: 'hello',
|
|
title: 'Title',
|
|
})).toEqual({ id: 'xfer-1' })
|
|
})
|
|
})
|
|
|
|
describe('parseShareHash / parseShareDeepLinkFields', () => {
|
|
it('parses url, text, and title from a fragment', () => {
|
|
expect(parseShareHash('#url=https%3A%2F%2Fexample.com&text=hello&title=Title')).toEqual({
|
|
url: 'https://example.com',
|
|
text: 'hello',
|
|
title: 'Title',
|
|
})
|
|
})
|
|
|
|
it('accepts a hash without a leading #', () => {
|
|
expect(parseShareHash('text=note')).toEqual({ text: 'note' })
|
|
})
|
|
|
|
it('omits empty content fields', () => {
|
|
expect(parseShareDeepLinkFields({ url: '', text: ' ', title: '' })).toEqual({})
|
|
})
|
|
|
|
it('preserves surrounding whitespace on non-empty content fields', () => {
|
|
expect(parseShareDeepLinkFields({
|
|
title: ' Title ',
|
|
text: ' indented\n',
|
|
url: ' https://example.com/path ',
|
|
})).toEqual({
|
|
title: ' Title ',
|
|
text: ' indented\n',
|
|
url: ' https://example.com/path ',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('hasShareDeepLinkContent', () => {
|
|
it('is true for url-only', () => {
|
|
expect(hasShareDeepLinkContent({ url: 'https://a.example' })).toBe(true)
|
|
})
|
|
|
|
it('is true for text-only', () => {
|
|
expect(hasShareDeepLinkContent({ text: 'note' })).toBe(true)
|
|
})
|
|
|
|
it('is true when both url and text are set', () => {
|
|
expect(hasShareDeepLinkContent({
|
|
url: 'https://a.example',
|
|
text: 'note',
|
|
})).toBe(true)
|
|
})
|
|
|
|
it('is false when empty', () => {
|
|
expect(hasShareDeepLinkContent({})).toBe(false)
|
|
})
|
|
|
|
it('is true when fileUrl is present', () => {
|
|
expect(hasShareDeepLinkContent({
|
|
fileUrl: 'http://127.0.0.1:9/s',
|
|
})).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('buildSharePayloadFromSearchFields', () => {
|
|
it('builds the same text payload shape as form-data (url-only)', () => {
|
|
expect(buildSharePayloadFromSearchFields(
|
|
{ url: 'https://example.com/page' },
|
|
1700000000000,
|
|
)).toEqual({
|
|
title: '',
|
|
text: '',
|
|
url: 'https://example.com/page',
|
|
files: [],
|
|
createdAt: 1700000000000,
|
|
})
|
|
})
|
|
|
|
it('builds text-only payload', () => {
|
|
expect(buildSharePayloadFromSearchFields(
|
|
{ text: 'Hello world' },
|
|
42,
|
|
)).toEqual({
|
|
title: '',
|
|
text: 'Hello world',
|
|
url: '',
|
|
files: [],
|
|
createdAt: 42,
|
|
})
|
|
})
|
|
|
|
it('builds combined title+text+url payload', () => {
|
|
expect(buildSharePayloadFromSearchFields({
|
|
title: 'My note',
|
|
text: 'Hello world',
|
|
url: 'https://example.com/page',
|
|
}, 99)).toEqual({
|
|
title: 'My note',
|
|
text: 'Hello world',
|
|
url: 'https://example.com/page',
|
|
files: [],
|
|
createdAt: 99,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('buildSharePayloadFromDeepLink', () => {
|
|
it('fetches fileUrl into files[]', async () => {
|
|
const bytes = new Uint8Array([1, 2, 3, 4])
|
|
const fetchMock = vi.fn(async () => new Response(bytes, {
|
|
status: 200,
|
|
headers: { 'content-type': 'image/jpeg' },
|
|
}))
|
|
const payload = await buildSharePayloadFromDeepLink(
|
|
{
|
|
title: 'Shot',
|
|
fileUrl: 'http://127.0.0.1:9/once',
|
|
fileName: 'shot.jpg',
|
|
fileType: 'image/jpeg',
|
|
},
|
|
55,
|
|
{ fetch: fetchMock as unknown as typeof fetch },
|
|
)
|
|
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:9/once')
|
|
expect(payload.title).toBe('Shot')
|
|
expect(payload.files).toHaveLength(1)
|
|
expect(payload.files[0]).toMatchObject({
|
|
name: 'shot.jpg',
|
|
type: 'image/jpeg',
|
|
})
|
|
expect(payload.files[0].blob.size).toBe(4)
|
|
expect(payload.createdAt).toBe(55)
|
|
})
|
|
|
|
it('throws when fileUrl fetch fails', async () => {
|
|
const fetchMock = vi.fn(async () => new Response(null, { status: 404 }))
|
|
await expect(buildSharePayloadFromDeepLink(
|
|
{ fileUrl: 'http://127.0.0.1:9/missing' },
|
|
1,
|
|
{ fetch: fetchMock as unknown as typeof fetch },
|
|
)).rejects.toThrow(/fileUrl fetch failed/)
|
|
})
|
|
|
|
it('rejects when Content-Length exceeds the upload ceiling', async () => {
|
|
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1]), {
|
|
status: 200,
|
|
headers: {
|
|
'content-type': 'application/octet-stream',
|
|
'content-length': String(51 * 1024 * 1024),
|
|
},
|
|
}))
|
|
await expect(buildSharePayloadFromDeepLink(
|
|
{ fileUrl: 'http://127.0.0.1:9/huge' },
|
|
1,
|
|
{ fetch: fetchMock as unknown as typeof fetch },
|
|
)).rejects.toThrow(/too large/)
|
|
})
|
|
|
|
it('rejects when streamed body exceeds the upload ceiling', async () => {
|
|
const chunk = new Uint8Array(1024 * 1024)
|
|
let reads = 0
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
reads += 1
|
|
if (reads <= 51) {
|
|
controller.enqueue(chunk)
|
|
return
|
|
}
|
|
controller.close()
|
|
},
|
|
})
|
|
const fetchMock = vi.fn(async () => new Response(stream, {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/octet-stream' },
|
|
}))
|
|
await expect(buildSharePayloadFromDeepLink(
|
|
{ fileUrl: 'http://127.0.0.1:9/stream' },
|
|
1,
|
|
{ fetch: fetchMock as unknown as typeof fetch },
|
|
)).rejects.toThrow(/too large/)
|
|
})
|
|
})
|
|
|
|
describe('ingestShareRequest', () => {
|
|
// jsdom/undici loses File objects when serializing FormData through
|
|
// `new Request({ body })` and re-parsing via `request.formData()`. The
|
|
// production SW only invokes Request#formData() once on the inbound
|
|
// multipart frame; tests substitute a stub that returns the FormData
|
|
// directly so the path under test (form -> payload -> put -> redirect)
|
|
// is exercised without depending on multipart roundtrip fidelity.
|
|
function makeRequest(formData: FormData): Request {
|
|
return {
|
|
formData: () => Promise.resolve(formData),
|
|
} as unknown as Request
|
|
}
|
|
|
|
it('persists payload via the put dep and returns a /share?id=… redirect', async () => {
|
|
const fd = new FormData()
|
|
fd.set('title', 'shared')
|
|
fd.append('files', new File([new Uint8Array([7])], 'a.bin', { type: '' }))
|
|
|
|
const put = vi.fn<(payload: ShareTransferPayload) => Promise<string>>()
|
|
.mockResolvedValue('xfer-abc')
|
|
|
|
const result = await ingestShareRequest(makeRequest(fd), {
|
|
put,
|
|
now: () => 9999,
|
|
})
|
|
|
|
expect(put).toHaveBeenCalledTimes(1)
|
|
const arg = put.mock.calls[0][0]
|
|
expect(arg.title).toBe('shared')
|
|
expect(arg.files).toHaveLength(1)
|
|
expect(arg.createdAt).toBe(9999)
|
|
expect(result.redirectTo).toBe('/share?id=xfer-abc')
|
|
})
|
|
|
|
it('encodes the transfer id so it survives querystring placement', async () => {
|
|
const put = vi.fn<(payload: ShareTransferPayload) => Promise<string>>()
|
|
.mockResolvedValue('contains spaces & ampersands')
|
|
|
|
const result = await ingestShareRequest(makeRequest(new FormData()), { put })
|
|
|
|
expect(result.redirectTo).toBe('/share?id=contains%20spaces%20%26%20ampersands')
|
|
})
|
|
|
|
it('propagates put rejections so the SW can fall back to error redirect', async () => {
|
|
const put = vi.fn<(payload: ShareTransferPayload) => Promise<string>>()
|
|
.mockRejectedValue(new Error('quota exceeded'))
|
|
|
|
await expect(
|
|
ingestShareRequest(makeRequest(new FormData()), { put })
|
|
).rejects.toThrow('quota exceeded')
|
|
})
|
|
})
|