|
1 | | -import { mkdtempSync } from 'node:fs' |
| 1 | +import { existsSync, mkdtempSync } from 'node:fs' |
2 | 2 | import { mkdir, rm, writeFile } from 'node:fs/promises' |
3 | 3 | import { tmpdir } from 'node:os' |
4 | 4 | import { dirname, join } from 'node:path' |
@@ -39,8 +39,11 @@ import { |
39 | 39 | createProjectFile, |
40 | 40 | updateProjectFileContent, |
41 | 41 | } from '@/lib/projects/files/application/content' |
| 42 | +import type { readPublicFileShareContent } from '@/lib/public-shares/application' |
42 | 43 | import * as shareManager from '@/lib/public-shares/manager' |
43 | 44 | import * as storage from '@/lib/uploads/core/storage-service' |
| 45 | +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' |
| 46 | +import { sanitizeFileKey } from '@/lib/uploads/utils/file' |
44 | 47 |
|
45 | 48 | vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) |
46 | 49 |
|
@@ -129,6 +132,16 @@ async function fixture() { |
129 | 132 | return { ...ids, principal: createSessionPrincipal({ userId: editorId }) } |
130 | 133 | } |
131 | 134 |
|
| 135 | +type PublicShareContent = Awaited<ReturnType<typeof readPublicFileShareContent>> |
| 136 | + |
| 137 | +/** The bytes a public read delivers, whether streamed from storage or built whole. */ |
| 138 | +async function sharedBytes(read: PublicShareContent | Promise<PublicShareContent>) { |
| 139 | + const result = await read |
| 140 | + return result.kind === 'stream' |
| 141 | + ? Buffer.from(await new Response(await result.open(undefined)).arrayBuffer()) |
| 142 | + : result.buffer |
| 143 | +} |
| 144 | + |
132 | 145 | function createInput(projectId: string, content = 'Project architecture') { |
133 | 146 | return { |
134 | 147 | projectId, |
@@ -410,9 +423,9 @@ describe('Public file shares against PostgreSQL and private storage', () => { |
410 | 423 | credential: { method: 'POST', password: 'correct-password' }, |
411 | 424 | }) |
412 | 425 | if (!accepted.authorized) throw new Error('Correct share password rejected') |
413 | | - expect((await readPublicFileShareContent({ grant: accepted.grant })).buffer.toString()).toBe( |
414 | | - 'Shared architecture' |
415 | | - ) |
| 426 | + expect( |
| 427 | + (await sharedBytes(readPublicFileShareContent({ grant: accepted.grant }))).toString() |
| 428 | + ).toBe('Shared architecture') |
416 | 429 | const [passwordShare] = await db |
417 | 430 | .select() |
418 | 431 | .from(publicShare) |
@@ -653,6 +666,74 @@ describe('Public file shares against PostgreSQL and private storage', () => { |
653 | 666 | } |
654 | 667 | ) |
655 | 668 |
|
| 669 | + check( |
| 670 | + 'public share streams stored bytes past the buffered ceiling and refuses once the share is disabled', |
| 671 | + async () => { |
| 672 | + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') |
| 673 | + const { authorizePublicFileShare, readPublicFileShareContent } = await import( |
| 674 | + '@/lib/public-shares/application' |
| 675 | + ) |
| 676 | + const f = await fixture() |
| 677 | + const share = async (name: string, contentType: string) => { |
| 678 | + const { file } = await createProjectFile.execute({ |
| 679 | + principal: f.principal, |
| 680 | + input: { ...createInput(f.projectId, 'placeholder'), name, contentType }, |
| 681 | + }) |
| 682 | + const { share } = await updateProjectFileShare.execute({ |
| 683 | + principal: f.principal, |
| 684 | + input: { projectId: f.projectId, fileId: file.id, isActive: true }, |
| 685 | + }) |
| 686 | + const access = await authorizePublicFileShare({ |
| 687 | + token: share.token, |
| 688 | + credential: { method: 'GET' }, |
| 689 | + }) |
| 690 | + if (!access.authorized) throw new Error(`Public ${name} fixture rejected`) |
| 691 | + return { file, grant: access.grant } |
| 692 | + } |
| 693 | + |
| 694 | + const recording = await share('recording.mp4', 'video/mp4') |
| 695 | + const size = MAX_BUFFERED_TRANSFER_BYTES + 1024 |
| 696 | + const bytes = Buffer.alloc(size) |
| 697 | + bytes.write('tail', size - 4) |
| 698 | + const [row] = await db |
| 699 | + .select() |
| 700 | + .from(workspaceFiles) |
| 701 | + .where(eq(workspaceFiles.id, recording.file.id)) |
| 702 | + const objectPath = join(localStorageRoot, sanitizeFileKey(row.key)) |
| 703 | + expect(existsSync(objectPath)).toBe(true) |
| 704 | + await writeFile(objectPath, bytes) |
| 705 | + await db |
| 706 | + .update(workspaceFiles) |
| 707 | + .set({ sizeBytes: size }) |
| 708 | + .where(eq(workspaceFiles.id, recording.file.id)) |
| 709 | + |
| 710 | + const stored = await readPublicFileShareContent({ grant: recording.grant }) |
| 711 | + if (stored.kind !== 'stream') throw new Error('A stored recording was buffered') |
| 712 | + expect(stored.size).toBe(size) |
| 713 | + const whole = Buffer.from(await new Response(await stored.open(undefined)).arrayBuffer()) |
| 714 | + expect(whole.length).toBe(size) |
| 715 | + expect(whole.subarray(size - 4).toString()).toBe('tail') |
| 716 | + const span = await stored.open({ start: size - 4, end: size - 1 }) |
| 717 | + expect(Buffer.from(await new Response(span).arrayBuffer()).toString()).toBe('tail') |
| 718 | + |
| 719 | + await updateProjectFileShare.execute({ |
| 720 | + principal: f.principal, |
| 721 | + input: { projectId: f.projectId, fileId: recording.file.id, isActive: false }, |
| 722 | + }) |
| 723 | + await expect(stored.open({ start: 0, end: 3 })).rejects.toMatchObject({ code: 'not_found' }) |
| 724 | + |
| 725 | + const page = await share('notes.html', 'text/html') |
| 726 | + expect(await readPublicFileShareContent({ grant: page.grant })).toMatchObject({ |
| 727 | + kind: 'buffer', |
| 728 | + }) |
| 729 | + // A generation source is compiled, never streamed: with no cached artifact it refuses. |
| 730 | + const source = await share('report.pptx', 'text/x-pptxgenjs') |
| 731 | + await expect(readPublicFileShareContent({ grant: source.grant })).rejects.toMatchObject({ |
| 732 | + code: 'conflict', |
| 733 | + }) |
| 734 | + } |
| 735 | + ) |
| 736 | + |
656 | 737 | check( |
657 | 738 | 'public share Office artifacts require the current owner-qualified dependency cache without compilation', |
658 | 739 | async () => { |
@@ -693,7 +774,7 @@ describe('Public file shares against PostgreSQL and private storage', () => { |
693 | 774 | principal: f.principal, |
694 | 775 | input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1024 }, |
695 | 776 | }) |
696 | | - expect((await readPublicFileShareContent({ grant: access.grant })).buffer).toEqual( |
| 777 | + expect(await sharedBytes(readPublicFileShareContent({ grant: access.grant }))).toEqual( |
697 | 778 | rendered.buffer |
698 | 779 | ) |
699 | 780 | await updateProjectFileContent.execute({ |
@@ -740,37 +821,49 @@ describe('Public file shares against PostgreSQL and private storage', () => { |
740 | 821 | credential: { method: 'POST', password: 'initial-password' }, |
741 | 822 | }) |
742 | 823 | if (!access.authorized) throw new Error('Initial credential rejected') |
| 824 | + const applyChange = async () => { |
| 825 | + if (change === 'disabled') |
| 826 | + await db |
| 827 | + .update(publicShare) |
| 828 | + .set({ isActive: false }) |
| 829 | + .where(eq(publicShare.id, share.id)) |
| 830 | + if (change === 'password') |
| 831 | + await db |
| 832 | + .update(publicShare) |
| 833 | + .set({ password: (await encryptSecret('replacement-password')).encrypted }) |
| 834 | + .where(eq(publicShare.id, share.id)) |
| 835 | + if (change === 'provenance') |
| 836 | + await db |
| 837 | + .update(workspaceFileSecretProvenance) |
| 838 | + .set({ status: 'unknown' }) |
| 839 | + .where(eq(workspaceFileSecretProvenance.fileId, file.id)) |
| 840 | + if (change === 'archived') |
| 841 | + await db |
| 842 | + .update(workspaceFiles) |
| 843 | + .set({ deletedAt: new Date() }) |
| 844 | + .where(eq(workspaceFiles.id, file.id)) |
| 845 | + } |
| 846 | + // The change lands while the object is being read, buffered or streamed alike. |
743 | 847 | const download = storage.downloadFile |
744 | | - const spy = vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { |
745 | | - const result = await download(options) |
746 | | - if (options.key === file.key) { |
747 | | - if (change === 'disabled') |
748 | | - await db |
749 | | - .update(publicShare) |
750 | | - .set({ isActive: false }) |
751 | | - .where(eq(publicShare.id, share.id)) |
752 | | - if (change === 'password') |
753 | | - await db |
754 | | - .update(publicShare) |
755 | | - .set({ password: (await encryptSecret('replacement-password')).encrypted }) |
756 | | - .where(eq(publicShare.id, share.id)) |
757 | | - if (change === 'provenance') |
758 | | - await db |
759 | | - .update(workspaceFileSecretProvenance) |
760 | | - .set({ status: 'unknown' }) |
761 | | - .where(eq(workspaceFileSecretProvenance.fileId, file.id)) |
762 | | - if (change === 'archived') |
763 | | - await db |
764 | | - .update(workspaceFiles) |
765 | | - .set({ deletedAt: new Date() }) |
766 | | - .where(eq(workspaceFiles.id, file.id)) |
767 | | - } |
768 | | - return result |
769 | | - }) |
770 | | - await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ |
| 848 | + const downloadStream = storage.downloadFileStream |
| 849 | + const spies = [ |
| 850 | + vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { |
| 851 | + const result = await download(options) |
| 852 | + if (options.key === file.key) await applyChange() |
| 853 | + return result |
| 854 | + }), |
| 855 | + vi.spyOn(storage, 'downloadFileStream').mockImplementation(async (options) => { |
| 856 | + const result = await downloadStream(options) |
| 857 | + if (options.key === file.key) await applyChange() |
| 858 | + return result |
| 859 | + }), |
| 860 | + ] |
| 861 | + await expect( |
| 862 | + sharedBytes(readPublicFileShareContent({ grant: access.grant })) |
| 863 | + ).rejects.toMatchObject({ |
771 | 864 | code: change === 'provenance' ? 'forbidden' : 'not_found', |
772 | 865 | }) |
773 | | - spy.mockRestore() |
| 866 | + for (const spy of spies) spy.mockRestore() |
774 | 867 | } |
775 | 868 | } |
776 | 869 | ) |
@@ -836,7 +929,7 @@ describe('Public file shares against PostgreSQL and private storage', () => { |
836 | 929 | }) |
837 | 930 | if (!legacyAccess.authorized) throw new Error('Existing workspace share rejected') |
838 | 931 | expect( |
839 | | - (await readPublicFileShareContent({ grant: legacyAccess.grant })).buffer.toString() |
| 932 | + (await sharedBytes(readPublicFileShareContent({ grant: legacyAccess.grant }))).toString() |
840 | 933 | ).toBe('Workspace link') |
841 | 934 | } |
842 | 935 | ) |
@@ -892,7 +985,7 @@ describe('Public file shares against PostgreSQL and private storage', () => { |
892 | 985 | if (!access.authorized) throw new Error('Public page rejected') |
893 | 986 | const rendered = await readPublicFileShareContent({ grant: access.grant }) |
894 | 987 | expect(rendered.contentType).toBe('text/html') |
895 | | - expect(rendered.buffer.toString()).toContain( |
| 988 | + expect((await sharedBytes(rendered)).toString()).toContain( |
896 | 989 | `data:image/png;base64,${image.toString('base64')}` |
897 | 990 | ) |
898 | 991 | expect( |
|
0 commit comments