diff --git a/apps/mobile/__tests__/lib/upload-file.test.ts b/apps/mobile/__tests__/lib/upload-file.test.ts new file mode 100644 index 0000000000..852008c7ca --- /dev/null +++ b/apps/mobile/__tests__/lib/upload-file.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { uploadFile, type LocalUpload } from '@/lib/upload-file' + +const { apiClientMock, fetchMock } = vi.hoisted(() => ({ + apiClientMock: vi.fn(), + fetchMock: vi.fn(), +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: apiClientMock, +})) + +vi.stubGlobal('fetch', fetchMock) + +const signed = { + key: 'user-1/abc.webp', + signedUrl: 'https://project.supabase.co/storage/v1/object/upload/sign/uploads/user-1/abc.webp?token=jwt', + publicUrl: 'https://project.supabase.co/storage/v1/object/public/uploads/user-1/abc.webp', +} + +const localUpload: LocalUpload = { + uri: 'file:///tmp/avatar.webp', + contentType: 'image/webp', + sizeBytes: 2048, +} + +describe('mobile uploadFile', () => { + beforeEach(() => { + apiClientMock.mockReset() + fetchMock.mockReset() + apiClientMock.mockResolvedValue(signed) + fetchMock.mockResolvedValue({ ok: true, status: 200 }) + }) + + it('signs through apiClient then PUTs the file to the signed url', async () => { + const result = await uploadFile(localUpload) + + expect(apiClientMock).toHaveBeenCalledWith('/api/uploads/sign', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + contentType: 'image/webp', + sizeBytes: 2048, + }), + })) + expect(fetchMock).toHaveBeenCalledWith(signed.signedUrl, expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'image/webp', 'x-upsert': 'true' }, + })) + expect(result).toEqual({ key: signed.key, publicUrl: signed.publicUrl }) + }) + + it('rejects a disallowed content type before signing', async () => { + const disallowed = { ...localUpload, contentType: 'application/pdf' as LocalUpload['contentType'] } + + await expect(uploadFile(disallowed)).rejects.toThrow(/Unsupported content type/) + expect(apiClientMock).not.toHaveBeenCalled() + }) + + it('throws when the upload PUT fails', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500 }) + + await expect(uploadFile(localUpload)).rejects.toThrow(/status 500/) + }) +}) diff --git a/apps/mobile/lib/upload-file.ts b/apps/mobile/lib/upload-file.ts new file mode 100644 index 0000000000..ce28c894e8 --- /dev/null +++ b/apps/mobile/lib/upload-file.ts @@ -0,0 +1,47 @@ +import { File } from 'expo-file-system' +import { + SignUploadResponseSchema, + UPLOAD_ALLOWED_CONTENT_TYPES, + type SignUploadResponse, + type StoredFile, +} from '@orbit/shared' +import { API } from '@orbit/shared/api' +import { apiClient } from './api-client' + +export interface LocalUpload { + uri: string + contentType: (typeof UPLOAD_ALLOWED_CONTENT_TYPES)[number] + sizeBytes: number +} + +/** + * Signs an upload through the API, PUTs the local file straight to object + * storage via its signed URL, and returns only the stored key + public URL. + */ +export async function uploadFile(upload: LocalUpload): Promise { + if (!(UPLOAD_ALLOWED_CONTENT_TYPES as readonly string[]).includes(upload.contentType)) { + throw new Error(`Unsupported content type: ${upload.contentType}`) + } + + const signed = SignUploadResponseSchema.parse( + await apiClient(API.uploads.sign, { + method: 'POST', + body: JSON.stringify({ + contentType: upload.contentType, + sizeBytes: upload.sizeBytes, + }), + }), + ) + + const response = await fetch(signed.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': upload.contentType, 'x-upsert': 'true' }, + body: new File(upload.uri), + }) + + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`) + } + + return { key: signed.key, publicUrl: signed.publicUrl } +} diff --git a/apps/web/__tests__/lib/upload-file.test.ts b/apps/web/__tests__/lib/upload-file.test.ts new file mode 100644 index 0000000000..1050126612 --- /dev/null +++ b/apps/web/__tests__/lib/upload-file.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const signUploadMock = vi.fn() +vi.mock('@/app/actions/uploads', () => ({ + signUpload: signUploadMock, +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const { uploadFile } = await import('@/lib/upload-file') + +const signed = { + key: 'user-1/abc.png', + signedUrl: 'https://project.supabase.co/storage/v1/object/upload/sign/uploads/user-1/abc.png?token=jwt', + publicUrl: 'https://project.supabase.co/storage/v1/object/public/uploads/user-1/abc.png', +} + +function makeFile(type = 'image/png'): File { + return new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type }) +} + +describe('web uploadFile', () => { + beforeEach(() => { + signUploadMock.mockReset() + fetchMock.mockReset() + signUploadMock.mockResolvedValue(signed) + fetchMock.mockResolvedValue({ ok: true, status: 200 }) + }) + + it('signs with file metadata then PUTs the file to the signed url', async () => { + const file = makeFile() + + const result = await uploadFile(file) + + expect(signUploadMock).toHaveBeenCalledWith({ + contentType: 'image/png', + sizeBytes: file.size, + }) + expect(fetchMock).toHaveBeenCalledWith(signed.signedUrl, expect.objectContaining({ + method: 'PUT', + body: file, + })) + expect(result).toEqual({ key: signed.key, publicUrl: signed.publicUrl }) + }) + + it('rejects a disallowed content type before signing', async () => { + await expect(uploadFile(makeFile('application/pdf'))).rejects.toThrow(/Unsupported content type/) + expect(signUploadMock).not.toHaveBeenCalled() + }) + + it('throws when the upload PUT fails', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500 }) + + await expect(uploadFile(makeFile())).rejects.toThrow(/status 500/) + }) +}) diff --git a/apps/web/app/actions/uploads.ts b/apps/web/app/actions/uploads.ts new file mode 100644 index 0000000000..e49622d126 --- /dev/null +++ b/apps/web/app/actions/uploads.ts @@ -0,0 +1,12 @@ +'use server' + +import type { SignUploadRequest, SignUploadResponse } from '@orbit/shared' +import { API } from '@orbit/shared/api' +import { serverAuthFetch } from '@/lib/server-fetch' + +export async function signUpload(input: SignUploadRequest): Promise { + return serverAuthFetch(API.uploads.sign, { + method: 'POST', + body: JSON.stringify(input), + }) +} diff --git a/apps/web/lib/upload-file.ts b/apps/web/lib/upload-file.ts new file mode 100644 index 0000000000..f4f4f1bbc6 --- /dev/null +++ b/apps/web/lib/upload-file.ts @@ -0,0 +1,41 @@ +import { + SignUploadResponseSchema, + UPLOAD_ALLOWED_CONTENT_TYPES, + type StoredFile, +} from '@orbit/shared' +import { signUpload } from '@/app/actions/uploads' + +type AllowedContentType = (typeof UPLOAD_ALLOWED_CONTENT_TYPES)[number] + +function assertAllowedContentType(contentType: string): asserts contentType is AllowedContentType { + if (!(UPLOAD_ALLOWED_CONTENT_TYPES as readonly string[]).includes(contentType)) { + throw new Error(`Unsupported content type: ${contentType}`) + } +} + +/** + * Signs an upload server-side, PUTs the file straight to object storage, + * and returns only the stored key + public URL to persist. + */ +export async function uploadFile(file: File): Promise { + assertAllowedContentType(file.type) + + const signed = SignUploadResponseSchema.parse( + await signUpload({ + contentType: file.type, + sizeBytes: file.size, + }), + ) + + const response = await fetch(signed.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type, 'x-upsert': 'true' }, + body: file, + }) + + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`) + } + + return { key: signed.key, publicUrl: signed.publicUrl } +} diff --git a/packages/shared/src/__tests__/upload.test.ts b/packages/shared/src/__tests__/upload.test.ts new file mode 100644 index 0000000000..da86140842 --- /dev/null +++ b/packages/shared/src/__tests__/upload.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { API } from '../api/endpoints' +import { uploadMutationKeys } from '../query' +import { + SignUploadRequestSchema, + SignUploadResponseSchema, + UPLOAD_MAX_SIZE_BYTES, +} from '../types/upload' + +describe('upload contract', () => { + it('exposes the sign endpoint', () => { + expect(API.uploads.sign).toBe('/api/uploads/sign') + }) + + it('builds the sign mutation key', () => { + expect(uploadMutationKeys.sign()).toEqual(['uploads', 'sign']) + }) + + describe('SignUploadRequestSchema', () => { + it('accepts an allowed image under the size cap', () => { + const parsed = SignUploadRequestSchema.safeParse({ + contentType: 'image/png', + sizeBytes: 1024, + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a disallowed content type', () => { + const parsed = SignUploadRequestSchema.safeParse({ + contentType: 'application/pdf', + sizeBytes: 1024, + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a file over the size cap', () => { + const parsed = SignUploadRequestSchema.safeParse({ + contentType: 'image/png', + sizeBytes: UPLOAD_MAX_SIZE_BYTES + 1, + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a non-positive size', () => { + const parsed = SignUploadRequestSchema.safeParse({ + contentType: 'image/png', + sizeBytes: 0, + }) + expect(parsed.success).toBe(false) + }) + }) + + describe('SignUploadResponseSchema', () => { + it('parses a well-formed response', () => { + const parsed = SignUploadResponseSchema.parse({ + key: 'user/file.png', + signedUrl: 'https://project.supabase.co/storage/v1/object/upload/sign/uploads/user/file.png?token=jwt', + publicUrl: 'https://project.supabase.co/storage/v1/object/public/uploads/user/file.png', + }) + expect(parsed.key).toBe('user/file.png') + }) + + it('rejects a non-URL signed url', () => { + const parsed = SignUploadResponseSchema.safeParse({ + key: 'user/file.png', + signedUrl: 'not-a-url', + publicUrl: 'https://project.supabase.co/x', + }) + expect(parsed.success).toBe(false) + }) + }) +}) diff --git a/packages/shared/src/api/endpoints.ts b/packages/shared/src/api/endpoints.ts index b4adbc9820..d0ab9fa8e8 100644 --- a/packages/shared/src/api/endpoints.ts +++ b/packages/shared/src/api/endpoints.ts @@ -167,4 +167,8 @@ export const API = { create: '/api/checklist-templates', delete: (id: string) => `/api/checklist-templates/${id}` as const, }, + + uploads: { + sign: '/api/uploads/sign', + }, } as const diff --git a/packages/shared/src/query/index.ts b/packages/shared/src/query/index.ts index b121d8ccb1..3fb2a900f4 100644 --- a/packages/shared/src/query/index.ts +++ b/packages/shared/src/query/index.ts @@ -14,6 +14,7 @@ export { userFactKeys, versionCheckKeys, checklistTemplateKeys, + uploadMutationKeys, } from './keys' export { diff --git a/packages/shared/src/query/keys.ts b/packages/shared/src/query/keys.ts index 7b81475233..cafa58fd81 100644 --- a/packages/shared/src/query/keys.ts +++ b/packages/shared/src/query/keys.ts @@ -100,3 +100,8 @@ export const versionCheckKeys = { all: ['version-check'] as const, latest: (pkg: string) => [...versionCheckKeys.all, pkg] as const, } + +export const uploadMutationKeys = { + all: ['uploads'] as const, + sign: () => [...uploadMutationKeys.all, 'sign'] as const, +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3629db109b..0a29de5f07 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -17,3 +17,4 @@ export * from './chat' export * from './ai' export * from './tour' export * from './data-export' +export * from './upload' diff --git a/packages/shared/src/types/upload.ts b/packages/shared/src/types/upload.ts new file mode 100644 index 0000000000..c0cf7fbcf2 --- /dev/null +++ b/packages/shared/src/types/upload.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +export const UPLOAD_ALLOWED_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const + +export const UPLOAD_MAX_SIZE_BYTES = 8 * 1024 * 1024 + +export const SignUploadRequestSchema = z.object({ + contentType: z.enum(UPLOAD_ALLOWED_CONTENT_TYPES), + sizeBytes: z.number().int().positive().max(UPLOAD_MAX_SIZE_BYTES), +}) + +export type SignUploadRequest = z.infer + +export const SignUploadResponseSchema = z.object({ + key: z.string().min(1), + signedUrl: z.string().url(), + publicUrl: z.string().url(), +}) + +export type SignUploadResponse = z.infer + +export type StoredFile = Pick