diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 7775d3032a8..3698bc1eb87 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -7,11 +7,11 @@ import { type DeploymentAuthResource, setDeploymentAuthCookie, } from '@/lib/core/security/deployment' -import { - type DeploymentAuthBody, - type DeploymentAuthResult, - validateDeploymentAuth, -} from '@/lib/core/security/deployment-auth' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import type { + DeploymentAuthBody, + DeploymentAuthResult, +} from '@/lib/core/security/deployment-credentials' export async function setChatAuthCookie( response: NextResponse, diff --git a/apps/sim/app/api/files/public/[token]/content/route.test.ts b/apps/sim/app/api/files/public/[token]/content/route.test.ts deleted file mode 100644 index 7ec6892222c..00000000000 --- a/apps/sim/app/api/files/public/[token]/content/route.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' - -const { mockEnforceRateLimit, mockValidateDeploymentAuth, mockResolveServableDoc } = vi.hoisted( - () => ({ - mockEnforceRateLimit: vi.fn(), - mockValidateDeploymentAuth: vi.fn(), - mockResolveServableDoc: vi.fn(), - }) -) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) - -vi.mock('@/lib/public-shares/rate-limit', () => ({ - enforcePublicFileRateLimit: mockEnforceRateLimit, -})) - -vi.mock('@/lib/core/security/deployment-auth', () => ({ - validateDeploymentAuth: mockValidateDeploymentAuth, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/uploads/documents/compile', () => ({ - resolveServableDoc: mockResolveServableDoc, -})) - -import { GET } from '@/app/api/files/public/[token]/content/route' - -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockDownloadFile = storageServiceMockFns.mockDownloadFile - -const params = (token = 'tok_1') => createRouteContext({ token }) -const request = (token = 'tok_1') => - createMockRequest({ url: `http://localhost/api/files/public/${token}/content` }) - -const passwordShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, - file: { - id: 'wf_1', - key: 'workspace/ws/secret-key.pdf', - workspaceId: 'ws-1', - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 4, - }, - workspaceName: 'Acme', - ownerName: 'Jane', -} - -describe('GET /api/files/public/[token]/content', () => { - beforeEach(() => { - mockEnforceRateLimit.mockResolvedValue(null) - mockResolveActiveShareByToken.mockResolvedValue(passwordShare) - mockDownloadFile.mockResolvedValue(Buffer.from('data')) - mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' }) - }) - - it('returns 401 and never reads storage when a password share is unauthorized', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'auth_required_password', - }) - const res = await GET(request(), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('auth_required_password') - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it('answers 413 rather than 500 when the shared file is too large to serve resident', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) - mockDownloadFile.mockRejectedValueOnce( - new PayloadSizeLimitError({ - label: 'storage download', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, - observedBytes: 5 * 1024 * 1024 * 1024, - }) - ) - - const res = await GET(request(), params()) - - expect(res.status).toBe(413) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 70c289dbd86..d8fcd3a3d3d 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -1,175 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' -import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import { resolveServableDoc } from '@/lib/uploads/documents/compile' -import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' -import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' import { - createFileErrorResponse, - createFileResponse, - FileNotFoundError, - getContentType, -} from '@/app/api/files/utils' + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authorizePublicFileShare, + checkPublicFileShareContent, + readPublicFileShareContent, +} from '@/lib/public-shares/application' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' +import { createConditionalFileResponse } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileContentAPI') -/** - * GET /api/files/public/[token]/content - * Public, unauthenticated bytes for a shared file. Authorized solely by an active - * share token — never by workspace membership. 404 for unknown/inactive/deleted - * shares. Disposition (inline vs attachment) is resolved from the file type by - * {@link createFileResponse}; the public page's Download button uses ``. - * - * Generated office docs are stored as source; {@link resolveServableDoc} swaps in - * their prebuilt compiled binary (read-only, never compiles). Uploaded binaries - * pass through untouched, except under `preview=1`, where a format no browser - * decodes is substituted with a renderable derivative. A generated doc whose - * compiled artifact isn't built yet returns 409 rather than serving raw source - * under a binary content type. - */ +/** Binary delivery rechecks the current bearer grant and preserves the token URL's revalidation policy. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'content') if (limited) return limited - const parsed = await parseRequest(getPublicFileContentContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const preview = parsed.data.query.preview === '1' - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file } = resolved - // The same ceiling the authenticated serve route reads this object under - // (`fetchWorkspaceFileBuffer`). Without it a share link is the one way to ask - // an unauthenticated caller's request to hold a 5 GB workspace file resident. - const raw = await downloadFile({ - key: file.key, - context: 'workspace', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + const auth = await authorizePublicFileShare({ + token: parsed.data.params.token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), }) - - const servable = file.workspaceId - ? await resolveServableDoc(file.workspaceId, raw, file.originalName, { - sourceMime: file.contentType, - }) - : ({ kind: 'passthrough' } as const) - - if (servable.kind === 'unavailable') { - logger.info('Public shared doc not yet compiled', { token, key: file.key }) - return NextResponse.json( - { error: 'This document is still being prepared. Please try again shortly.' }, - { status: 409 } - ) - } - - // This response is `nosniff`, so a stored `application/octet-stream` refuses to render - // even though the bytes are fine. Resolving from the filename also keeps this route on - // the same inline allowlist as the workspace serve route. - let buffer = raw - let contentType = getContentType(file.originalName) - if (servable.kind === 'artifact') { - buffer = servable.buffer - contentType = servable.contentType - } else if ( - // Sim pages store an extensionless name — the record type marks them; - // legacy pages still carry .html. - (file.contentType === SIM_PAGE_CONTENT_TYPE || - file.originalName.toLowerCase().endsWith('.html')) && - isSimPageSource(raw.toString('utf8')) - ) { - // The pdf model for pages: the stored .html is source; a share serves - // the fully styled compiled document, matching the preview and serve - // routes. sim: links resolve to workspace routes (a viewer without - // workspace access simply lands on the sign-in gate). - buffer = Buffer.from( - await renderSimPageDocumentWithAssets(raw.toString('utf8'), { - workspaceId: file.workspaceId ?? undefined, - }), - 'utf8' - ) - contentType = 'text/html' - } else if (preview) { - // Only for a render request: the Download button omits `preview`, so a saved - // file is always the bytes that were shared. - const image = await resolveServableImageBytes(raw, file.key) - if (image) ({ buffer, contentType } = image) + if (!auth.authorized) return publicFileAuthDenied(auth) + if (request.method === 'HEAD') { + await checkPublicFileShareContent(auth.grant) + return new NextResponse(null, { + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.revalidate, + 'X-Content-Type-Options': 'nosniff', + }, + }) } - - // Bounding the source read does not bound the response: each branch above can - // replace it with bytes fetched or produced separately — a compiled artifact, a - // page with its images inlined, a transcoded derivative. This is an anonymous - // route, so the bytes it actually returns are what has to fit. - assertKnownSizeWithinLimit(buffer.length, MAX_BUFFERED_TRANSFER_BYTES, 'served file response') - - logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) - - // Anonymous access: null actor (owner-as-actor would misread as a self-download). - recordAudit({ - workspaceId: file.workspaceId ?? null, - actorId: null, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.originalName, - description: `Public share download of "${file.originalName}"`, - metadata: { - access: 'public_share', - anonymous: true, - sharedByUserId: file.userId, - fileName: file.originalName, - bytes: buffer.length, - }, + const result = await readPublicFileShareContent({ + grant: auth.grant, + preview: parsed.data.query.preview === '1', request, }) - - // Revalidate every request: a shared file can be unshared, edited, or deleted, - // so the fixed token URL must never serve stale bytes from a long-lived cache. - return createFileResponse({ - buffer, - contentType, - filename: file.originalName, - cacheControl: 'private, no-cache, must-revalidate', - }) + return createConditionalFileResponse( + { + buffer: result.buffer, + contentType: result.contentType, + filename: result.file.originalName, + cacheControl: FILE_CACHE_CONTROL.revalidate, + }, + request.headers.get('if-none-match') + ) } catch (error) { logger.error('Error serving public shared file:', error) - if (error instanceof FileNotFoundError) { - return createFileErrorResponse(error) - } - return createFileErrorResponse( - error instanceof Error ? error : new Error('Failed to serve file') - ) + return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } ) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts deleted file mode 100644 index bbdc860e5c8..00000000000 --- a/apps/sim/app/api/files/public/[token]/inline/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' - -const { mockRateLimit, mockValidateAuth, mockResolveImage } = vi.hoisted(() => ({ - mockRateLimit: vi.fn(), - mockValidateAuth: vi.fn(), - mockResolveImage: vi.fn(), -})) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) -vi.mock('@/lib/public-shares/rate-limit', () => ({ enforcePublicFileRateLimit: mockRateLimit })) -vi.mock('@/lib/core/security/deployment-auth', () => ({ validateDeploymentAuth: mockValidateAuth })) -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) -vi.mock('@/lib/uploads/server/inline-image', () => ({ - resolveWorkspaceInlineImage: mockResolveImage, -})) - -import { GET } from '@/app/api/files/public/[token]/inline/route' - -const { mockResolveActiveShareByToken: mockResolveShare } = publicSharesMockFns - -const mockDownloadFile = storageServiceMockFns.mockDownloadFile - -const TOKEN = 'tok_share_123456' -const DOC_KEY = 'workspace/ws-1/doc.md' -const IMG_KEY = 'workspace/ws-1/photo.png' -const FILE_ID = 'wf_YwDXi8eWOkTxn0sbgChlB' -const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) - -const params = createRouteContext({ token: TOKEN }) -const req = (q: string) => - createMockRequest({ url: `http://localhost/api/files/public/${TOKEN}/inline?${q}` }) - -const share = { - share: { id: 'sh_1', token: TOKEN, authType: 'public' }, - file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' }, - workspaceName: 'Acme', - ownerName: 'Jane', -} - -/** doc bytes embed the image via the view form; image bytes are a real PNG */ -function downloadByKey(docContent = `![a](/api/files/view/${FILE_ID})`) { - return ({ key }: { key: string }) => - Promise.resolve(key === DOC_KEY ? Buffer.from(docContent, 'utf-8') : PNG) -} - -describe('GET /api/files/public/[token]/inline', () => { - beforeEach(() => { - mockRateLimit.mockResolvedValue(null) - mockResolveShare.mockResolvedValue(share) - mockValidateAuth.mockResolvedValue({ authorized: true }) - mockResolveImage.mockResolvedValue({ - key: IMG_KEY, - contentType: 'image/png', - filename: 'photo.png', - }) - mockDownloadFile.mockImplementation(downloadByKey()) - }) - - it('rejects exhausted image budgets before share lookup, authentication, or storage reads', async () => { - const limited = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers: { 'Retry-After': '60' } } - ) - mockRateLimit.mockResolvedValue(limited) - - const response = await GET(req(`fileId=${FILE_ID}`), params) - - expect(response.status).toBe(429) - expect(response.headers.get('Retry-After')).toBe('60') - expect(mockResolveShare).not.toHaveBeenCalled() - expect(mockValidateAuth).not.toHaveBeenCalled() - expect(mockResolveImage).not.toHaveBeenCalled() - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it.each(['fileId', 'key'] as const)( - 'serves a referenced %s beyond the export bundle limit', - async (kind) => { - const earlierImages = Array.from( - { length: 50 }, - (_, index) => `![earlier](/api/files/view/wf_earlier_${index})` - ) - const src = - kind === 'fileId' - ? `/api/files/view/${FILE_ID}` - : `/api/files/serve/${encodeURIComponent(IMG_KEY)}` - mockDownloadFile.mockImplementation( - downloadByKey([...earlierImages, `![last](${src})`].join('\n\n')) - ) - - const response = await GET( - req(`${kind}=${encodeURIComponent(kind === 'fileId' ? FILE_ID : IMG_KEY)}`), - params - ) - - expect(response.status).toBe(200) - expect(mockResolveImage).toHaveBeenCalledExactlyOnceWith('ws-1', { - [kind]: kind === 'fileId' ? FILE_ID : IMG_KEY, - }) - } - ) - - it('404s when the reference is not embedded in the shared document', async () => { - mockDownloadFile.mockImplementation(downloadByKey('no images here')) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() - }) - - it.each([ - `[link](/api/files/view/${FILE_ID})`, - `\`![image](/api/files/view/${FILE_ID})\``, - ``, - ``, - `
`, - `inline text`, - `![external](https://example.com/api/files/view/${FILE_ID})`, - ])('does not extend a share to an image mentioned as %s', async (source) => { - mockDownloadFile.mockImplementation(downloadByKey(source)) - - const response = await GET(req(`fileId=${FILE_ID}`), params) - - expect(response.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - }) - - it('404s when the referenced file is not in the document workspace', async () => { - mockResolveImage.mockResolvedValue(null) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(404) - }) - - it('401s and never reads storage when the share is unauthorized', async () => { - mockValidateAuth.mockResolvedValue({ authorized: false, error: 'auth_required_password' }) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(401) - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it('bounds both reads: the doc scan tightly, the served image at the transfer ceiling', async () => { - await GET(req(`fileId=${FILE_ID}`), params) - - const [docRead, imageRead] = mockDownloadFile.mock.calls.map(([args]) => args) - // The doc is scanned and discarded (and decoded to UTF-16 on top of the buffer), - // so it must not inherit the ceiling of a file this route actually serves. - expect(docRead.key).toBe(DOC_KEY) - expect(docRead.maxBytes).toBeGreaterThan(0) - expect(docRead.maxBytes).toBeLessThan(MAX_BUFFERED_TRANSFER_BYTES) - expect(imageRead.key).toBe(IMG_KEY) - expect(imageRead.maxBytes).toBe(MAX_BUFFERED_TRANSFER_BYTES) - }) - - it('fails the referenced-by-doc gate closed when the document is too large to scan', async () => { - mockDownloadFile.mockImplementation(({ key }: { key: string }) => - key === DOC_KEY - ? Promise.reject( - new PayloadSizeLimitError({ - label: 'storage download', - maxBytes: 10 * 1024 * 1024, - observedBytes: 5 * 1024 * 1024 * 1024, - }) - ) - : Promise.resolve(PNG) - ) - - const res = await GET(req(`fileId=${FILE_ID}`), params) - - expect(res.status).toBe(404) - // The gate could not be verified, so the image must never be read at all. - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index f72547accc1..638eec5dda0 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -1,143 +1,55 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authorizePublicFileShare, + readPublicFileShareInline, +} from '@/lib/public-shares/application' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' -import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' -import { serveInlineImage } from '@/app/api/files/serve-inline-image' -import { createFileErrorResponse, FileNotFoundError } from '@/app/api/files/utils' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' +import { createConditionalFileResponse } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicInlineFileAPI') -/** - * Ceiling on the shared document read for the referenced-by-doc gate below. - * - * Far tighter than the ceiling on a file this route SERVES, because these bytes are - * never served — they are scanned for image references and discarded, and scanning - * decodes them to UTF-16 on top of the buffer, so the resident cost is roughly double - * the read. A share can point at any workspace file, admitted at 5 GB, and this route - * is anonymous; nothing a person writes as a document approaches even this bound. - */ -const MAX_INLINE_REF_SCAN_BYTES = 10 * 1024 * 1024 - -/** - * GET /api/files/public/[token]/inline?key=|fileId= - * - * Cascades a markdown document's public share to the images it embeds, so a logged-out viewer sees them - * instead of broken icons. The share grants the document bytes; this route extends that grant to the - * document's referenced images only, behind three gates that together hold the security boundary: - * - * 1. Referenced-by-doc — the requested key/id must be embedded as an image by the shared document's - * current bytes. The token is a capability for the document and its embeds, never an arbitrary - * workspace file, and never one the document merely links to or mentions in prose. - * 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace - * ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author - * can write but must never resolve) from loading. - * 3. Content-truth — the served content type is sniffed from the bytes, not the client-declared type, - * and only genuine raster images are served. A file spoofing `image/png` while holding HTML/SVG is - * refused rather than rendered inline. - */ +/** The bearer operation extends a document's grant only to its current same-owner raster embeds. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'inline') if (limited) return limited - const parsed = await parseRequest(getPublicInlineFileContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const ref = parsed.data.query - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file: doc } = resolved - if (!doc.workspaceId) { - throw new FileNotFoundError('Not found') - } - - // Referenced-by-doc gate: the share grants exactly the images the document embeds. - // A document too large to scan fails the gate like any other unverifiable - // reference — the grant cannot be extended to an embed we were unable to confirm. - let docText: string - try { - const docBuffer = await downloadFile({ - key: doc.key, - context: 'workspace', - maxBytes: MAX_INLINE_REF_SCAN_BYTES, - }) - docText = docBuffer.toString('utf-8') - } catch (error) { - if (!isPayloadSizeLimitError(error)) throw error - logger.info('Shared document too large to scan for embedded references', { token }) - throw new FileNotFoundError('Not found') - } - const target = ref.fileId ? { fileId: ref.fileId } : ref.key ? { key: ref.key } : null - if (!target || !hasEmbeddedFileRef(docText, target)) { - throw new FileNotFoundError('Not found') - } - - // Same-workspace gate: resolve scoped to the document's own workspace. - const image = await resolveWorkspaceInlineImage(doc.workspaceId, ref) - if (!image) { - throw new FileNotFoundError('Not found') - } - - // Content-truth gate (`sniff`): render only genuine raster image bytes; audit after. - const response = await serveInlineImage(image, { sniff: true }) - - // Anonymous access: null actor (owner-as-actor would misread as a self-download). - recordAudit({ - workspaceId: doc.workspaceId, - actorId: null, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceName: image.filename, - description: `Public share inline image "${image.filename}"`, - metadata: { - access: 'public_share', - anonymous: true, - inline: true, - sharedByUserId: doc.userId, - }, + const auth = await authorizePublicFileShare({ + token: parsed.data.params.token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), + }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const result = await readPublicFileShareInline({ + grant: auth.grant, + ...parsed.data.query, request, }) - - return response + return createConditionalFileResponse( + { + buffer: result.buffer, + contentType: result.contentType, + filename: result.servedFileName, + cacheControl: FILE_CACHE_CONTROL.revalidate, + }, + request.headers.get('if-none-match') + ) } catch (error) { - if (error instanceof FileNotFoundError) { - return createFileErrorResponse(error) - } logger.error('Error serving public inline image:', error) - return createFileErrorResponse( - error instanceof Error ? error : new Error('Failed to serve file') - ) + return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } ) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts deleted file mode 100644 index 77a8c162acd..00000000000 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { requestUtilsMockFns } from '@sim/testing' -import { createRouteContext } from '@sim/testing/helpers/http' -import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock' -import { emailTemplatesMock, emailTemplatesMockFns } from '@sim/testing/mocks/email-templates.mock' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsEmailAllowed, - mockSetDeploymentAuthCookie, - mockGenerateOTP, - mockStoreOTP, - mockGetOTP, - mockDeleteOTP, - mockIncrementOTPAttempts, - mockDecodeOTPValue, - mockAfterResponse, -} = vi.hoisted(() => ({ - mockIsEmailAllowed: vi.fn(), - mockSetDeploymentAuthCookie: vi.fn(), - mockGenerateOTP: vi.fn(), - mockStoreOTP: vi.fn(), - mockGetOTP: vi.fn(), - mockDeleteOTP: vi.fn(), - mockIncrementOTPAttempts: vi.fn(), - mockDecodeOTPValue: vi.fn(), - mockAfterResponse: vi.fn(), -})) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) -vi.mock('@/lib/core/security/deployment', () => ({ - isEmailAllowed: mockIsEmailAllowed, - setDeploymentAuthCookie: mockSetDeploymentAuthCookie, -})) -vi.mock('@/lib/core/security/otp', () => ({ - generateOTP: mockGenerateOTP, - storeOTP: mockStoreOTP, - getOTP: mockGetOTP, - deleteOTP: mockDeleteOTP, - incrementOTPAttempts: mockIncrementOTPAttempts, - decodeOTPValue: mockDecodeOTPValue, - MAX_OTP_ATTEMPTS: 5, - OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, - OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, - OTP_RESOURCE_RATE_LIMIT: { maxTokens: 100, refillRate: 100, refillIntervalMs: 1000 }, -})) -vi.mock('@/components/emails', () => emailTemplatesMock) -vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) -vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) -vi.mock('@/lib/core/utils/after-response', () => ({ - afterResponse: mockAfterResponse, -})) - -import { PUT, POST as routePost } from '@/app/api/files/public/[token]/otp/route' - -const { mockSendEmail } = emailMailerMockFns -const { mockRenderOTPEmail } = emailTemplatesMockFns -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockCheckRateLimitDirect = rateLimiterMockFns.mockCheckRateLimitDirect - -const POST: typeof routePost = async (...args) => { - const response = await routePost(...args) - const task = mockAfterResponse.mock.calls.at(-1)?.[0] as (() => Promise) | undefined - if (task) await task() - return response -} - -const params = (token = 'tok_1') => createRouteContext({ token }) -const post = (email: string, token = 'tok_1') => - createMockRequest({ - method: 'POST', - url: `http://localhost/api/files/public/${token}/otp`, - body: { email }, - }) -const put = (email: string, otp: string, token = 'tok_1') => - createMockRequest({ - method: 'PUT', - url: `http://localhost/api/files/public/${token}/otp`, - body: { email, otp }, - }) - -const emailShare = { - share: { id: 'sh_1', authType: 'email', password: null, allowedEmails: ['@acme.com'] }, - file: { originalName: 'report.pdf' }, -} - -describe('POST /api/files/public/[token]/otp', () => { - beforeEach(() => { - mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) - mockResolveActiveShareByToken.mockResolvedValue(emailShare) - mockIsEmailAllowed.mockReturnValue(true) - mockGenerateOTP.mockReturnValue('123456') - mockRenderOTPEmail.mockResolvedValue('') - mockSendEmail.mockResolvedValue({ success: true }) - }) - - it('returns the generic acceptance response for an email not on the allow-list', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - const res = await POST(post('user@evil.com'), params()) - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('does not consume a send bucket for a rejected email without a client IP', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - mockIsEmailAllowed.mockReturnValueOnce(false) - - const res = await POST(post('user@evil.com'), params()) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockAfterResponse).toHaveBeenCalledTimes(1) - expect(mockCheckRateLimitDirect).not.toHaveBeenCalled() - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('returns 429 when the IP rate limit is exceeded', async () => { - mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) - const res = await POST(post('user@acme.com'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('1') - }) - - it('returns the generic acceptance response when the email rate limit is exceeded', async () => { - mockCheckRateLimitDirect - .mockResolvedValueOnce({ allowed: true }) - .mockResolvedValueOnce({ allowed: true }) - .mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('retains resource and email backstops when the client IP cannot be resolved', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2) - expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( - 1, - 'file-otp:resource:sh_1', - expect.any(Object), - { failClosed: true } - ) - expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( - 2, - 'file-otp:email:sh_1:user@acme.com', - expect.any(Object), - { failClosed: true } - ) - }) -}) - -describe('PUT /api/files/public/[token]/otp', () => { - beforeEach(() => { - mockResolveActiveShareByToken.mockResolvedValue(emailShare) - mockIsEmailAllowed.mockReturnValue(true) - mockGetOTP.mockResolvedValue('123456:0') - mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 }) - }) - - it('rejects a valid code when the email is no longer allowed', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - - const res = await PUT(put('user@acme.com', '123456'), params()) - - expect(res.status).toBe(403) - expect(mockGetOTP).not.toHaveBeenCalled() - expect(mockDeleteOTP).not.toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('rejects a wrong code with 400 and increments attempts', async () => { - mockIncrementOTPAttempts.mockResolvedValueOnce('incremented') - const res = await PUT(put('user@acme.com', '000000'), params()) - expect(res.status).toBe(400) - expect(mockIncrementOTPAttempts).toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 429 when attempts are exhausted on a wrong code', async () => { - mockIncrementOTPAttempts.mockResolvedValueOnce('locked') - const res = await PUT(put('user@acme.com', '000000'), params()) - expect(res.status).toBe(429) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 87dec5ec3e0..11b9fcadbcb 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -1,216 +1,76 @@ import { createLogger } from '@sim/logger' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestPublicFileOtpContract, verifyPublicFileOtpContract, } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' -import { isEmailAllowed, setDeploymentAuthCookie } from '@/lib/core/security/deployment' -import { - decodeOTPValue, - deleteOTP, - generateOTP, - getOTP, - incrementOTPAttempts, - MAX_OTP_ATTEMPTS, - OTP_EMAIL_RATE_LIMIT, - OTP_IP_RATE_LIMIT, - OTP_RESOURCE_RATE_LIMIT, - storeOTP, -} from '@/lib/core/security/otp' +import { OTP_IP_RATE_LIMIT } from '@/lib/core/security/otp' import { afterResponse } from '@/lib/core/utils/after-response' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { + requestPublicFileShareOtp, + verifyPublicFileShareOtp, +} from '@/lib/public-shares/application' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileOtpAPI') - const rateLimiter = new RateLimiter() -const SHARE_EMAIL_LABEL = 'a shared file' - -function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { - const response = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) - response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) - return response -} - -function otpRequestAccepted(): NextResponse { - return NextResponse.json({ message: 'Verification code sent' }) -} - -async function deliverOtp(requestId: string, shareId: string, email: string): Promise { - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:resource:${shareId}`, - OTP_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] OTP resource rate limit exceeded for share ${shareId}`) - return - } - - const emailRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:email:${shareId}:${email}`, - OTP_EMAIL_RATE_LIMIT, - { failClosed: true } - ) - if (!emailRateLimit.allowed) { - logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`) - return - } - - const otp = generateOTP() - await storeOTP('file', shareId, email, otp) - - const emailHtml = await renderOTPEmail(otp, 'email-verification', SHARE_EMAIL_LABEL) - const emailResult = await sendEmail({ - to: email, - subject: getOtpSubject(SHARE_EMAIL_LABEL), - html: emailHtml, - }) - if (!emailResult.success) { - logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return - } - - logger.info(`[${requestId}] OTP sent for share ${shareId}`) -} - -/** - * POST /api/files/public/[token]/otp - * Sends a 6-digit verification code to an allow-listed email for an email-gated share. - */ +/** Accepted email requests retain one response; the application delivers only to authorized recipients. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const ip = getClientIp(request) if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( + const limited = await rateLimiter.checkRateLimitDirect( `file-otp:ip:${ip}`, OTP_IP_RATE_LIMIT, { failClosed: true } ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`) - return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs) - } + if (!limited.allowed) + return publicFileAuthDenied({ + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limited.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs, + }) } - const parsed = await parseRequest(requestPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - // Normalize once so allow-list matching, OTP storage, and the verify lookup - // all key off the same value (allow-list entries are stored lowercase). - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'email') { - return NextResponse.json( - { error: 'This file does not use email authentication' }, - { status: 400 } - ) - } - const emailAllowed = isEmailAllowed(email, resolved.share.allowedEmails) - - afterResponse(async () => { - if (!emailAllowed) return - await deliverOtp(requestId, resolved.share.id, email) + const accepted = await requestPublicFileShareOtp({ + token: parsed.data.params.token, + email: parsed.data.body.email, }) - return otpRequestAccepted() + afterResponse(accepted.deliver) + return NextResponse.json({ message: 'Verification code sent' }) } catch (error) { - logger.error(`[${requestId}] Error processing OTP request:`, error) - return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + logger.error('Error processing OTP request:', error) + return publicFileErrorResponse(error, 'Failed to process request') } } ) -/** - * PUT /api/files/public/[token]/otp - * Verifies the code and, on success, sets the `file_auth_{shareId}` cookie. - */ +/** The application consumes a correct OTP and returns the current resource-policy cookie. */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(verifyPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const { otp } = parsed.data.body - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'email') { - return NextResponse.json( - { error: 'This file does not use email authentication' }, - { status: 400 } - ) - } - if (!isEmailAllowed(email, resolved.share.allowedEmails)) { - return NextResponse.json({ error: 'Email not authorized' }, { status: 403 }) - } - - const storedValue = await getOTP('file', resolved.share.id, email) - if (!storedValue) { - return NextResponse.json( - { error: 'No verification code found, request a new one' }, - { status: 400 } - ) - } - - const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) - if (attempts >= MAX_OTP_ATTEMPTS) { - await deleteOTP('file', resolved.share.id, email) - return NextResponse.json( - { error: 'Too many failed attempts. Please request a new code.' }, - { status: 429 } - ) - } - - if (storedOTP !== otp) { - const result = await incrementOTPAttempts('file', resolved.share.id, email, storedValue) - if (result === 'locked') { - return NextResponse.json( - { error: 'Too many failed attempts. Please request a new code.' }, - { status: 429 } - ) - } - return NextResponse.json({ error: 'Invalid verification code' }, { status: 400 }) - } - - await deleteOTP('file', resolved.share.id, email) - - const response = NextResponse.json({ authType: resolved.share.authType }) - await setDeploymentAuthCookie({ - response, - cookiePrefix: 'file', - resource: resolved.share, - verifiedEmail: email, + const auth = await verifyPublicFileShareOtp({ + token: parsed.data.params.token, + ...parsed.data.body, }) - logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`) + if (!auth.authorized) return publicFileAuthDenied(auth) + const response = NextResponse.json({ authType: auth.authType }) + response.cookies.set(auth.cookie) return response } catch (error) { - logger.error(`[${requestId}] Error verifying OTP:`, error) - return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + logger.error('Error verifying OTP:', error) + return publicFileErrorResponse(error, 'Failed to process request') } } ) diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts deleted file mode 100644 index bace6e2f8fb..00000000000 --- a/apps/sim/app/api/files/public/[token]/route.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnforceRateLimit, mockValidateDeploymentAuth, mockSetDeploymentAuthCookie } = - vi.hoisted(() => ({ - mockEnforceRateLimit: vi.fn(), - mockValidateDeploymentAuth: vi.fn(), - mockSetDeploymentAuthCookie: vi.fn(), - })) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) - -vi.mock('@/lib/public-shares/rate-limit', () => ({ - enforcePublicFileRateLimit: mockEnforceRateLimit, -})) - -vi.mock('@/lib/core/security/deployment-auth', () => ({ - validateDeploymentAuth: mockValidateDeploymentAuth, -})) - -vi.mock('@/lib/core/security/deployment', () => ({ - setDeploymentAuthCookie: mockSetDeploymentAuthCookie, -})) - -import { NextResponse } from 'next/server' -import { GET, POST } from '@/app/api/files/public/[token]/route' - -const mockResolveActiveShareByToken = publicSharesMockFns.mockResolveActiveShareByToken - -const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) -const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`) -const postRequest = (password: string, token = 'tok_1') => - new NextRequest(`http://localhost/api/files/public/${token}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ password }), - }) - -const publicShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, - file: { - id: 'wf_1', - key: 'workspace/ws/secret-key.pdf', - workspaceId: 'ws-secret', - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 2048, - }, - workspaceName: 'Acme Workspace', - ownerName: 'Jane Doe', -} - -const passwordShare = { - ...publicShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, -} - -describe('GET /api/files/public/[token]', () => { - beforeEach(() => { - mockEnforceRateLimit.mockResolvedValue(null) // allow by default - mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default - }) - - it('returns 429 when the per-IP rate limit is exceeded', async () => { - mockEnforceRateLimit.mockResolvedValueOnce( - NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }) - ) - const res = await GET(request(), params()) - expect(res.status).toBe(429) - expect(mockResolveActiveShareByToken).not.toHaveBeenCalled() - }) - - it('returns public-safe metadata without leaking the key or workspace id', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce(publicShare) - const res = await GET(request(), params()) - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ - token: 'tok_1', - name: 'report.pdf', - type: 'application/pdf', - size: 2048, - workspaceName: 'Acme Workspace', - ownerName: 'Jane Doe', - }) - expect(JSON.stringify(body)).not.toContain('secret-key') - expect(JSON.stringify(body)).not.toContain('ws-secret') - }) - - it('returns 401 auth_required_password for a password share without a valid cookie', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare) - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'auth_required_password', - }) - const res = await GET(request(), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('auth_required_password') - expect(mockValidateDeploymentAuth).toHaveBeenCalledWith( - expect.any(String), - passwordShare.share, - expect.anything(), - undefined, - 'file' - ) - }) -}) - -describe('POST /api/files/public/[token]', () => { - beforeEach(() => { - mockResolveActiveShareByToken.mockResolvedValue(passwordShare) - }) - - it('sets the file_auth cookie and returns the authType on a correct password', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) - const res = await POST(postRequest('hunter2'), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ authType: 'password' }) - expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ - response: expect.anything(), - cookiePrefix: 'file', - resource: passwordShare.share, - }) - }) - - it('refuses to mint a cookie for a non-password (e.g. public) share', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce({ - ...passwordShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, - }) - const res = await POST(postRequest('whatever'), params()) - expect(res.status).toBe(400) - expect(mockValidateDeploymentAuth).not.toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 401 Invalid password on mismatch without setting a cookie', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'Invalid password', - }) - const res = await POST(postRequest('wrong'), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('Invalid password') - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 429 with Retry-After when password attempts are rate-limited', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'Too many attempts. Please try again later.', - status: 429, - retryAfterMs: 60_000, - }) - const res = await POST(postRequest('wrong'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('60') - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index 68493446c05..2c2aed33059 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { @@ -7,54 +6,39 @@ import { getPublicFileContract, } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { + publicFileAuthDenied, + publicFileErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authenticatePublicFileSharePassword, + authorizePublicFileShare, + readPublicFileShare, +} from '@/lib/public-shares/application' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileMetadataAPI') -/** - * GET /api/files/public/[token] - * Public, unauthenticated metadata for a shared file. Returns 404 for unknown, - * inactive, or deleted shares — the existence of a file is never leaked. A - * password-protected share returns 401 `auth_required_password` until a valid - * `file_auth_{shareId}` cookie is present. - */ +/** Public bearer authentication and cookie exchange retain their protocol-specific response shape. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'metadata') if (limited) return limited - const parsed = await parseRequest(getPublicFileContract, request, context) if (!parsed.success) return parsed.response const { token } = parsed.data.params - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file, workspaceName, ownerName } = resolved + const auth = await authorizePublicFileShare({ + token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), + }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const { file, workspaceName, ownerName } = await readPublicFileShare({ grant: auth.grant }) return NextResponse.json({ token, name: file.originalName, @@ -65,78 +49,29 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Error fetching public file metadata:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to fetch file') }, - { status: 500 } - ) + return publicFileErrorResponse(error, 'Failed to fetch file') } } ) -/** - * POST /api/files/public/[token] - * Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited - * via the shared deployment-auth gate; returns 401 (`Invalid password`) on - * mismatch and 429 (with `Retry-After`) when throttled. - */ +/** Exchanges a current password policy for its resource-bound HttpOnly cookie. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(authenticatePublicFileContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const { password } = parsed.data.body - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - // This endpoint authenticates password shares only. Refusing other modes - // here prevents minting a `file_auth` cookie for a `public` share (which - // `validateDeploymentAuth` would otherwise authorize), which could later - // satisfy the gate if the share is switched to `email`/`sso`. - if (resolved.share.authType !== 'password') { - return NextResponse.json( - { error: 'This file does not use password authentication' }, - { status: 400 } - ) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - { password }, - 'file' - ) - if (!auth.authorized) { - const response = NextResponse.json( - { error: auth.error ?? 'Invalid password' }, - { status: auth.status ?? 401 } - ) - if (auth.status === 429 && auth.retryAfterMs !== undefined) { - response.headers.set('Retry-After', String(Math.ceil(auth.retryAfterMs / 1000))) - } - return response - } - - const response = NextResponse.json({ authType: resolved.share.authType }) - await setDeploymentAuthCookie({ - response, - cookiePrefix: 'file', - resource: resolved.share, + const auth = await authenticatePublicFileSharePassword({ + token: parsed.data.params.token, + password: parsed.data.body.password, + clientIp: getClientIp(request), }) - logger.info('Public file share password accepted', { token, shareId: resolved.share.id }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const response = NextResponse.json({ authType: auth.authType }) + response.cookies.set(auth.cookie) return response } catch (error) { logger.error('Error authenticating public file share:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to authenticate') }, - { status: 500 } - ) + return publicFileErrorResponse(error, 'Failed to authenticate') } } ) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.test.ts b/apps/sim/app/api/files/public/[token]/sso/route.test.ts deleted file mode 100644 index 1cbbc38749c..00000000000 --- a/apps/sim/app/api/files/public/[token]/sso/route.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { requestUtilsMockFns } from '@sim/testing' -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockIsEmailAllowed } = vi.hoisted(() => ({ - mockIsEmailAllowed: vi.fn(), -})) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) -vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed })) -vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) - -import { POST } from '@/app/api/files/public/[token]/sso/route' - -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockCheckRateLimitDirect = rateLimiterMockFns.mockCheckRateLimitDirect - -const params = (token = 'tok_1') => createRouteContext({ token }) -const post = (email: string, token = 'tok_1') => - createMockRequest({ - method: 'POST', - url: `http://localhost/api/files/public/${token}/sso`, - body: { email }, - }) - -const ssoShare = { - share: { id: 'sh_1', authType: 'sso', password: null, allowedEmails: ['@acme.com'] }, - file: { originalName: 'report.pdf' }, -} - -describe('POST /api/files/public/[token]/sso', () => { - beforeEach(() => { - mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) - mockResolveActiveShareByToken.mockResolvedValue(ssoShare) - }) - - it('returns eligible:false for a non-listed email', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - const res = await POST(post('user@evil.com'), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ eligible: false }) - }) - - it('returns 429 when rate-limited', async () => { - mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 2000 }) - const res = await POST(post('user@acme.com'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('2') - }) - - it('uses the share resource limit when the client IP cannot be resolved', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) - expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( - 'file-sso:resource:sh_1', - expect.objectContaining({ maxTokens: 100 }), - { failClosed: true } - ) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts index 2ab675b5af8..9718d8a1e86 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -1,94 +1,54 @@ import { createLogger } from '@sim/logger' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { publicFileSSOContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import type { TokenBucketConfig } from '@/lib/core/rate-limiter' -import { RateLimiter } from '@/lib/core/rate-limiter' -import { isEmailAllowed } from '@/lib/core/security/deployment' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { getPublicFileShareSsoEligibility } from '@/lib/public-shares/application' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' - const logger = createLogger('PublicFileSSOAPI') - const rateLimiter = new RateLimiter() - const SSO_IP_RATE_LIMIT: TokenBucketConfig = { maxTokens: 20, refillRate: 20, refillIntervalMs: 15 * 60_000, } -const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 15 * 60_000, -} - -function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { - const response = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) - response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) - return response -} - -/** - * POST /api/files/public/[token]/sso - * Reports whether an email is on the allow-list for an SSO-gated share. The actual - * authentication is the global Sim session (checked at the page/route gate). - */ +/** Eligibility preserves the existing SSO redirect flow and never grants file access by itself. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - - const ip = getClientIp(request) - if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `file-sso:ip:${ip}`, - SSO_IP_RATE_LIMIT, - { failClosed: true } - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) - return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs) + try { + const ip = getClientIp(request) + if (ip) { + const limited = await rateLimiter.checkRateLimitDirect( + `file-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT, + { failClosed: true } + ) + if (!limited.allowed) + return publicFileAuthDenied({ + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limited.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs, + }) } - } - - const parsed = await parseRequest(publicFileSSOContract, request, context) - if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'sso') { - return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 }) - } - - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `file-sso:resource:${resolved.share.id}`, - SSO_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, { - shareId: resolved.share.id, + const parsed = await parseRequest(publicFileSSOContract, request, context) + if (!parsed.success) return parsed.response + const result = await getPublicFileShareSsoEligibility({ + token: parsed.data.params.token, + email: parsed.data.body.email, }) - return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs) + return result.allowed + ? NextResponse.json({ eligible: result.eligible }) + : publicFileAuthDenied(result) + } catch (error) { + logger.error('Error checking public file SSO eligibility:', error) + return publicFileErrorResponse(error, 'Failed to process request') } - - const allowedEmails = Array.isArray(resolved.share.allowedEmails) - ? (resolved.share.allowedEmails as string[]) - : [] - return NextResponse.json({ eligible: isEmailAllowed(email, allowedEmails) }) } ) diff --git a/apps/sim/app/api/files/serve-inline-image.ts b/apps/sim/app/api/files/serve-inline-image.ts deleted file mode 100644 index f984413fd52..00000000000 --- a/apps/sim/app/api/files/serve-inline-image.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { NextResponse } from 'next/server' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { sniffImageContentType } from '@/lib/uploads/utils/validation' -import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils' - -const logger = createLogger('InlineImageServe') - -/** - * An embedded image is authenticated content served from a fixed inline URL, and the file behind it can - * be DELETED or its access REVOKED at any time — so it always revalidates, letting each request re-run the - * server-side deletion/authorization check rather than serving a stale (possibly no-longer-authorized) - * image from cache. Private so no shared cache/CDN ever stores it. - */ -const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate' - -/** - * Download and respond with an already-workspace-scoped inline image — the single serving tail for both - * the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the - * served content type is derived from the bytes and non-raster content is refused with 404; otherwise the - * stored content type is served, matching the in-app serve route. - */ -export async function serveInlineImage( - image: ResolvedInlineImage, - { sniff }: { sniff: boolean } -): Promise { - const buffer = await downloadFile({ - key: image.key, - context: 'workspace', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, - }) - - let contentType = image.contentType - if (sniff) { - const sniffed = sniffImageContentType(buffer) - if (!sniffed) { - logger.warn('Embedded reference is not a renderable image', { key: image.key }) - throw new FileNotFoundError('Not found') - } - contentType = sniffed - } - - return createFileResponse({ - buffer, - contentType, - filename: image.filename, - cacheControl: INLINE_CACHE_CONTROL, - }) -} diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..717e120acaa --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts @@ -0,0 +1,34 @@ +import { + getProjectFileShareContract, + updateProjectFileShareContract, +} from '@/lib/api/contracts/project-file-shares' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getProjectFileShare, + projectFileOperations, + updateProjectFileShare, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectFileShareContract, + auth: internalSessionAuth, + operation: projectFileOperations.readShare, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: getProjectFileShare, +}) +export const PUT = defineInternalJsonRoute({ + contract: updateProjectFileShareContract, + auth: internalSessionAuth, + operation: projectFileOperations.updateShare, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }), + useCase: updateProjectFileShare, +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..8b27b2d7791 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts @@ -0,0 +1,41 @@ +import { + v2GetProjectFileShareContract, + v2UpdateProjectFileShareContract, +} from '@/lib/api/contracts/v2/project-file-shares' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + getProjectFileShare, + updateProjectFileShare, +} from '@/lib/projects/files/application/shares' + +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileShareContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), + useCase: getProjectFileShare, + present: ({ share }) => ({ data: share }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateProjectFileShareContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileId: params.fileId, + ...body, + }), + useCase: updateProjectFileShare, + present: ({ share }) => ({ data: share }), +}) diff --git a/apps/sim/app/f/[token]/opengraph-image.tsx b/apps/sim/app/f/[token]/opengraph-image.tsx index db89ff9eff3..72c6b33f6cb 100644 --- a/apps/sim/app/f/[token]/opengraph-image.tsx +++ b/apps/sim/app/f/[token]/opengraph-image.tsx @@ -1,5 +1,5 @@ import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { readPublicFileSocialMetadata } from '@/lib/public-shares/api' import { buildProvenance } from '@/app/f/[token]/utils' export const dynamic = 'force-dynamic' @@ -14,16 +14,16 @@ export const size = COVER_OG_SIZE */ export default async function Image({ params }: { params: Promise<{ token: string }> }) { const { token } = await params - const resolved = await resolveActiveShareByToken(token) + const resolved = await readPublicFileSocialMetadata(token) - if (!resolved || resolved.share.authType !== 'public') { + if (!resolved || resolved.protected) { return createCoverOgImage({ title: 'Protected file', subtitle: 'Authentication is required to view this file', }) } - const { file, workspaceName, ownerName } = resolved + const { file, workspaceName, ownerName } = resolved.metadata return createCoverOgImage({ title: file.originalName, diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 8722e80b832..e0172687298 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -1,14 +1,9 @@ -import { cache } from 'react' import type { Metadata } from 'next' import { cookies } from 'next/headers' import { notFound } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { - deploymentAuthCookieName, - isEmailAllowed, - validateAuthToken, -} from '@/lib/core/security/deployment' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { publicFileShareCredential, readPublicFileSocialMetadata } from '@/lib/public-shares/api' +import { authorizePublicFileShare, readPublicFileShare } from '@/lib/public-shares/application' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { PublicFileAuth } from '@/app/f/[token]/public-file-auth' import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' @@ -19,9 +14,6 @@ import { getBrandConfig } from '@/ee/whitelabeling' export const dynamic = 'force-dynamic' -/** Deduped per-request so `generateMetadata` and the page share one DB resolve. */ -const resolveShare = cache(resolveActiveShareByToken) - /** Shared links must never be indexed by search engines. */ const NOINDEX = { index: false, follow: false } as const @@ -36,20 +28,21 @@ interface PublicFilePageProps { */ export async function generateMetadata({ params }: PublicFilePageProps): Promise { const { token } = await params - const resolved = await resolveShare(token) + const resolved = await readPublicFileSocialMetadata(token) if (!resolved) { return { robots: NOINDEX } } let title: string let description: string - if (resolved.share.authType !== 'public') { + if (resolved.protected) { title = 'Shared file' description = 'Authentication is required to view this file.' } else { - title = resolved.file.originalName + title = resolved.metadata.file.originalName description = - buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file · ${title}` + buildProvenance(resolved.metadata.workspaceName, resolved.metadata.ownerName) || + `Shared file · ${title}` } const brand = getBrandConfig() @@ -62,66 +55,35 @@ export async function generateMetadata({ params }: PublicFilePageProps): Promise } } -/** The auth-relevant slice of a resolved share row. */ -interface GateShare { - id: string - authType: string - password: string | null - allowedEmails: unknown -} - -/** - * Returns the auth prompt to render when a protected share is not yet authorized, - * or `null` when the visitor may view the file. `password`/`email` use the - * `file_auth_{shareId}` cookie; `sso` uses the global Sim session. - */ -async function renderAuthGate(token: string, share: GateShare) { - if (share.authType === 'public') return null - - if (share.authType === 'sso') { - const session = await getSession() - const allowedEmails = Array.isArray(share.allowedEmails) - ? (share.allowedEmails as string[]) - : [] - const authorized = Boolean( - session?.user?.email && isEmailAllowed(session.user.email, allowedEmails) - ) - return authorized ? null : - } - - const cookieStore = await cookies() - const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value - if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null - - return share.authType === 'email' ? ( - - ) : ( - - ) -} - export default async function PublicFilePage({ params }: PublicFilePageProps) { const { token } = await params - - const resolved = await resolveShare(token) - if (!resolved) { - notFound() + try { + const auth = await authorizePublicFileShare({ + token, + credential: await publicFileShareCredential((await cookies()).getAll()), + }) + if (!auth.authorized) { + if (auth.authType === 'sso') return + if (auth.authType === 'email') return + return + } + const { file, owner, workspaceName, ownerName } = await readPublicFileShare({ + grant: auth.grant, + }) + return ( + + ) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') notFound() + throw error } - - const { share, file, workspaceName, ownerName } = resolved - - const gate = await renderAuthGate(token, share) - if (gate) return gate - - return ( - - ) } diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 4b57b43f56d..11d67c49e73 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -6,6 +6,7 @@ import { Download } from '@sim/emcn/icons' import Link from 'next/link' import { SITE_URL } from '@/lib/core/utils/urls' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { buildProvenance } from '@/app/f/[token]/utils' import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -14,6 +15,7 @@ import { createPublicFileContentSource } from '@/hooks/use-file-content-source' interface PublicFileViewProps { token: string + owner: EditableFileOwner name: string type: string size: number @@ -25,6 +27,7 @@ interface PublicFileViewProps { export function PublicFileView({ token, + owner, name, type, size, @@ -43,10 +46,10 @@ export function PublicFileView({ // storage key + `updatedAt`) refetch when the shared file changes — even when its // size is unchanged. // Embedded images route through the token-scoped cascade endpoint, which serves them only when the - // shared document actually references them and they live in its workspace. + // shared document actually references them and they have the same canonical owner. const source = useMemo( - () => createPublicFileContentSource(token, contentUrl), - [token, contentUrl] + () => createPublicFileContentSource(token, contentUrl, owner), + [token, contentUrl, owner] ) const file = useMemo( () => ({ diff --git a/apps/sim/hooks/queries/public-shares.ts b/apps/sim/hooks/queries/public-shares.ts index e2552b12531..5f33d20d163 100644 --- a/apps/sim/hooks/queries/public-shares.ts +++ b/apps/sim/hooks/queries/public-shares.ts @@ -19,7 +19,7 @@ export const FILE_SHARE_STALE_TIME = 30 * 1000 /** * Query key factories for public shares */ -export const shareKeys = { +const shareKeys = { all: ['publicShares'] as const, details: () => [...shareKeys.all, 'detail'] as const, detail: (workspaceId: string, fileId: string) => diff --git a/apps/sim/lib/api/contracts/project-file-shares.ts b/apps/sim/lib/api/contracts/project-file-shares.ts new file mode 100644 index 00000000000..8c8c8f7c618 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-shares.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { + projectFileCapabilitiesSchema, + projectFileParamsSchema, +} from '@/lib/api/contracts/project-files' +import { + shareAuthTypeSchema, + shareRecordSchema, + upsertFileShareBodySchema, +} from '@/lib/api/contracts/public-shares' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const projectFileSharePolicySchema = z.object({ + canPublish: z + .boolean() + .describe( + 'Whether current Project sharing policies permit publishing or updating an active share.' + ), + allowedAuthTypes: z + .array(shareAuthTypeSchema) + .max(4) + .describe('Authentication modes permitted by every applicable active environment policy.'), +}) +export type ProjectFileSharePolicy = z.output +export const getProjectFileShareResponseSchema = z.object({ + share: shareRecordSchema.nullable(), + policy: projectFileSharePolicySchema, + capabilities: projectFileCapabilitiesSchema, +}) +export type GetProjectFileShareResponse = z.output +export const updateProjectFileShareBodySchema = upsertFileShareBodySchema +export type UpdateProjectFileShareBody = z.input +export const updateProjectFileShareResponseSchema = z.object({ share: shareRecordSchema }) +export type UpdateProjectFileShareResponse = z.output +export const getProjectFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/share', + params: projectFileParamsSchema, + response: { mode: 'json', schema: getProjectFileShareResponseSchema }, +}) +export const updateProjectFileShareContract = defineRouteContract({ + method: 'PUT', + path: '/api/projects/[id]/files/[fileId]/share', + params: projectFileParamsSchema, + body: updateProjectFileShareBodySchema, + response: { mode: 'json', schema: updateProjectFileShareResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/public-shares.ts b/apps/sim/lib/api/contracts/public-shares.ts index 84a6362fc69..4fd88574d3b 100644 --- a/apps/sim/lib/api/contracts/public-shares.ts +++ b/apps/sim/lib/api/contracts/public-shares.ts @@ -65,8 +65,6 @@ const getFileShareResponseSchema = z.object({ share: shareRecordSchema.nullable(), }) -export type GetFileShareResponse = z.output - export const getFileShareContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/files/[fileId]/share', @@ -138,7 +136,7 @@ export const getPublicFileContentContract = defineRouteContract({ /** * Binary stream of an image embedded in a shared document. Authorized by the parent * document's active share — the route serves the bytes only when the reference is - * actually embedded in the shared document AND the file lives in the same workspace, + * actually embedded in the shared document AND the file has the same canonical owner, * and only when the bytes are a renderable raster image. */ export const getPublicInlineFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts new file mode 100644 index 00000000000..b8dde906cc4 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts @@ -0,0 +1,75 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2GetProjectFileShareContract, + v2UpdateProjectFileShareContract, +} from '@/lib/api/contracts/v2/project-file-shares' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileShareOpenApiRoutes = [ + defineOpenApiRoute( + v2GetProjectFileShareContract, + { + applicationOperation: projectFileOperations.readShare, + operationId: 'getProjectFileShare', + summary: 'Get Project File Share', + description: `Get a Project file's public-share configuration. An unshared file returns data: null; a disabled share retains its configuration with isActive: false. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'Current nullable file-share state.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileShareContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2GetProjectFileShareContract.query, + response: documentedSchema( + v2GetProjectFileShareContract.response.schema, + 'V2GetProjectFileShareResponse', + 'Get Project file share response', + 'Current public-share state for a Project file.' + ), + } + ), + defineOpenApiRoute( + v2UpdateProjectFileShareContract, + { + applicationOperation: projectFileOperations.updateShare, + operationId: 'updateProjectFileShare', + summary: 'Update Project File Share', + description: `Create or update a Project file's public share. isActive is required; omitted settings retain their current values except credentials unused by the selected access mode, which are cleared. Disabling retains the token and access configuration. Publication requires Project edit access and the current sharing policy across accessible active environments. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'The updated Project file share.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdateProjectFileShareContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2UpdateProjectFileShareContract.query, + body: documentedSchema( + v2UpdateProjectFileShareContract.body, + 'UpdateProjectFileShareRequest', + 'Update Project file share request', + 'Desired public-share state and access policy. Share tokens are generated by the server.' + ), + response: documentedSchema( + v2UpdateProjectFileShareContract.response.schema, + 'V2UpdateProjectFileShareResponse', + 'Update Project file share response', + 'Updated public-share state for a Project file.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts b/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts index a454f1ebc08..d40f695b630 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts @@ -1,6 +1,7 @@ import { unpublishedFileCopyOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/files-audit' import { projectFileDownloadOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-downloads' import { projectFileFolderOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-folders' +import { projectFileShareOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-shares' import { projectFileUploadOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-uploads' import { projectFileVersionOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-versions' import { projectFileOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-files' @@ -16,5 +17,6 @@ export const unpublishedProjectFileDocumentation = { ...projectFileVersionOpenApiRoutes, ...projectFileFolderOpenApiRoutes, ...projectFileUploadOpenApiRoutes, + ...projectFileShareOpenApiRoutes, ], } diff --git a/apps/sim/lib/api/contracts/v2/project-file-shares.ts b/apps/sim/lib/api/contracts/v2/project-file-shares.ts new file mode 100644 index 00000000000..d5dda56f280 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-shares.ts @@ -0,0 +1,39 @@ +import type { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { sharePasswordSchema } from '@/lib/api/contracts/public-shares' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2FileShareSchema, + v2NullableFileShareSchema, + v2UpsertFileShareBodySchema, +} from '@/lib/api/contracts/v2/files' +import { v2ProjectFileParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2UpdateProjectFileShareBodySchema = v2UpsertFileShareBodySchema + .omit({ workspaceId: true }) + .extend({ + password: sharePasswordSchema + .optional() + .describe( + 'Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected.' + ), + }) +export type V2UpdateProjectFileShareBody = z.input + +export const v2GetProjectFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2NullableFileShareSchema) }, +}) + +export const v2UpdateProjectFileShareContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2UpdateProjectFileShareBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileShareSchema) }, +}) diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index 07fcf763782..173f838e7fc 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -490,6 +490,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/projects/{projectId}/files/{fileId}/restore', load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/restore/route'), }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/share', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/share/route'), + }, { pattern: '/api/v2/projects/{projectId}/files/{fileId}/unzip', load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route'), diff --git a/apps/sim/lib/core/security/deployment-auth.ts b/apps/sim/lib/core/security/deployment-auth.ts index 23e94fdcdb1..d178946c0ba 100644 --- a/apps/sim/lib/core/security/deployment-auth.ts +++ b/apps/sim/lib/core/security/deployment-auth.ts @@ -1,76 +1,20 @@ import { createLogger } from '@sim/logger' -import { safeCompare } from '@sim/security/compare' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' -import type { TokenBucketConfig } from '@/lib/core/rate-limiter' -import { RateLimiter } from '@/lib/core/rate-limiter' import { type DeploymentAuthKind, type DeploymentAuthResource, deploymentAuthCookieName, - isEmailAllowed, - readDeploymentAuthToken, } from '@/lib/core/security/deployment' -import { decryptSecret } from '@/lib/core/security/encryption' +import { + type DeploymentAuthBody, + type DeploymentAuthResult, + validateDeploymentCredentials, +} from '@/lib/core/security/deployment-credentials' import { getClientIp } from '@/lib/core/utils/request' const logger = createLogger('DeploymentAuth') -const rateLimiter = new RateLimiter() - -/** - * Throttles unauthenticated password guesses per client IP against a single - * deployment, mirroring the OTP/SSO IP limits. - */ -const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 10, - refillRate: 10, - refillIntervalMs: 15 * 60_000, -} - -/** - * Caps guesses against one resource independently of client identity. This is - * the backstop for distributed attempts and for requests whose proxy chain - * cannot be resolved safely. - */ -const PASSWORD_RESOURCE_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 15 * 60_000, -} - -function passwordRateLimitResult( - retryAfterMs: number | undefined, - fallbackMs: number -): DeploymentAuthResult { - return { - authorized: false, - error: 'Too many attempts. Please try again later.', - status: 429, - retryAfterMs: retryAfterMs ?? fallbackMs, - } -} - -export interface DeploymentAuthBody { - password?: string - email?: string - input?: unknown -} - -export interface DeploymentAuthResult { - authorized: boolean - authenticatedEmail?: string - error?: string - status?: number - retryAfterMs?: number -} - -/** - * Shared password/email/SSO gate for deployed resources. The `cookiePrefix` - * selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit - * namespace so chat deployments and public file shares share one code path. Both - * support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`. - */ +/** Adapts authenticated session identity and HTTP credentials to the common deployment gate. */ export async function validateDeploymentAuth( requestId: string, resource: DeploymentAuthResource, @@ -78,153 +22,30 @@ export async function validateDeploymentAuth( parsedBody: DeploymentAuthBody | null | undefined, cookiePrefix: DeploymentAuthKind ): Promise { - const authType = resource.authType || 'public' - - if (authType === 'public') { - return { authorized: true } - } - - if (authType === 'password' || authType === 'email') { - const authCookie = request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id)) - - if (authCookie) { - const claims = await readDeploymentAuthToken({ token: authCookie.value, resource }) - if (claims) return { authorized: true, ...claims } - } - } - - if (authType === 'password') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_password' } - } - + let sessionEmail: string | null | undefined + let sessionPresent = false + if (resource.authType === 'sso' && (request.method === 'GET' || parsedBody)) { try { - if (!parsedBody) { - return { authorized: false, error: 'Password is required' } - } - - const { password, input } = parsedBody - - if (input && !password) { - return { authorized: false, error: 'auth_required_password' } - } - - if (!password) { - return { authorized: false, error: 'Password is required' } - } - - if (!resource.password) { - logger.error(`[${requestId}] No password set for password-protected ${resource.id}`) - return { authorized: false, error: 'Authentication configuration error' } - } - - const ip = getClientIp(request) - if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `${cookiePrefix}-password:ip:${resource.id}:${ip}`, - PASSWORD_IP_RATE_LIMIT, - { failClosed: true } - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, { - resourceId: resource.id, - cookiePrefix, - ip, - }) - return passwordRateLimitResult( - ipRateLimit.retryAfterMs, - PASSWORD_IP_RATE_LIMIT.refillIntervalMs - ) - } - } - - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `${cookiePrefix}-password:resource:${resource.id}`, - PASSWORD_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, { - resourceId: resource.id, - cookiePrefix, - }) - return passwordRateLimitResult( - resourceRateLimit.retryAfterMs, - PASSWORD_RESOURCE_RATE_LIMIT.refillIntervalMs - ) - } - - const { decrypted } = await decryptSecret(resource.password) - if (!safeCompare(password, decrypted)) { - return { authorized: false, error: 'Invalid password' } - } - - return { authorized: true } - } catch (error) { - logger.error(`[${requestId}] Error validating password:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'email') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_email' } - } - - try { - if (!parsedBody) { - return { authorized: false, error: 'Email is required' } - } - - const { email, input } = parsedBody - - if (input && !email) { - return { authorized: false, error: 'auth_required_email' } - } - - if (!email) { - return { authorized: false, error: 'Email is required' } - } - - if (isEmailAllowed(email, resource.allowedEmails)) { - return { authorized: false, error: 'otp_required' } - } - - return { authorized: false, error: 'Email not authorized' } - } catch (error) { - logger.error(`[${requestId}] Error validating email:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'sso') { - try { - if (request.method !== 'GET' && !parsedBody) { - return { authorized: false, error: 'SSO authentication is required' } - } - const { getSession } = await import('@/lib/auth') const session = await getSession() - - if (!session || !session.user) { - return { authorized: false, error: 'auth_required_sso' } - } - - const userEmail = session.user.email - if (!userEmail) { - return { authorized: false, error: 'SSO session does not contain email' } - } - - if (isEmailAllowed(userEmail, resource.allowedEmails)) { - return { authorized: true, authenticatedEmail: normalizeEmail(userEmail) } - } - - return { authorized: false, error: 'Your email is not authorized to access this resource' } + sessionPresent = Boolean(session?.user) + sessionEmail = session?.user?.email } catch (error) { logger.error(`[${requestId}] Error validating SSO:`, error) return { authorized: false, error: 'SSO authentication error' } } } - - return { authorized: false, error: 'Unsupported authentication type' } + return validateDeploymentCredentials( + requestId, + resource, + { + method: request.method, + authToken: request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id))?.value, + clientIp: getClientIp(request), + sessionPresent, + sessionEmail, + }, + parsedBody, + cookiePrefix + ) } diff --git a/apps/sim/lib/core/security/deployment-credentials.ts b/apps/sim/lib/core/security/deployment-credentials.ts new file mode 100644 index 00000000000..1faa9f9858e --- /dev/null +++ b/apps/sim/lib/core/security/deployment-credentials.ts @@ -0,0 +1,233 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { normalizeEmail } from '@sim/utils/string' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { + type DeploymentAuthKind, + type DeploymentAuthResource, + isEmailAllowed, + readDeploymentAuthToken, +} from '@/lib/core/security/deployment' +import { decryptSecret } from '@/lib/core/security/encryption' + +const logger = createLogger('DeploymentAuth') + +const rateLimiter = new RateLimiter() + +/** + * Throttles unauthenticated password guesses per client IP against a single + * deployment, mirroring the OTP/SSO IP limits. + */ +const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + +/** + * Caps guesses against one resource independently of client identity. This is + * the backstop for distributed attempts and for requests whose proxy chain + * cannot be resolved safely. + */ +const PASSWORD_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} + +function passwordRateLimitResult( + retryAfterMs: number | undefined, + fallbackMs: number +): DeploymentAuthResult { + return { + authorized: false, + error: 'Too many attempts. Please try again later.', + status: 429, + retryAfterMs: retryAfterMs ?? fallbackMs, + } +} + +export interface DeploymentAuthBody { + password?: string + email?: string + input?: unknown +} + +export interface DeploymentAuthResult { + authorized: boolean + authenticatedEmail?: string + error?: string + status?: number + retryAfterMs?: number +} + +/** Verified session context and raw request credentials; callers obtain session identity from authentication. */ +export interface DeploymentCredentialContext { + method: string + authToken?: string + clientIp?: string | null + sessionPresent?: boolean + sessionEmail?: string | null +} + +/** + * Shared password/email/SSO gate for deployed resources. The `cookiePrefix` + * selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit + * namespace so chat deployments and public file shares share one code path. Both + * support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`. + */ +export async function validateDeploymentCredentials( + requestId: string, + resource: DeploymentAuthResource, + request: DeploymentCredentialContext, + parsedBody: DeploymentAuthBody | null | undefined, + cookiePrefix: DeploymentAuthKind +): Promise { + const authType = resource.authType || 'public' + + if (authType === 'public') { + return { authorized: true } + } + + if (authType === 'password' || authType === 'email') { + const authCookie = request.authToken + + if (authCookie) { + const claims = await readDeploymentAuthToken({ token: authCookie, resource }) + if (claims) return { authorized: true, ...claims } + } + } + + if (authType === 'password') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_password' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Password is required' } + } + + const { password, input } = parsedBody + + if (input && !password) { + return { authorized: false, error: 'auth_required_password' } + } + + if (!password) { + return { authorized: false, error: 'Password is required' } + } + + if (!resource.password) { + logger.error(`[${requestId}] No password set for password-protected ${resource.id}`) + return { authorized: false, error: 'Authentication configuration error' } + } + + const ip = request.clientIp + if (ip) { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `${cookiePrefix}-password:ip:${resource.id}:${ip}`, + PASSWORD_IP_RATE_LIMIT, + { failClosed: true } + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, { + resourceId: resource.id, + cookiePrefix, + ip, + }) + return passwordRateLimitResult( + ipRateLimit.retryAfterMs, + PASSWORD_IP_RATE_LIMIT.refillIntervalMs + ) + } + } + + const resourceRateLimit = await rateLimiter.checkRateLimitDirect( + `${cookiePrefix}-password:resource:${resource.id}`, + PASSWORD_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceRateLimit.allowed) { + logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, { + resourceId: resource.id, + cookiePrefix, + }) + return passwordRateLimitResult( + resourceRateLimit.retryAfterMs, + PASSWORD_RESOURCE_RATE_LIMIT.refillIntervalMs + ) + } + + const { decrypted } = await decryptSecret(resource.password) + if (!safeCompare(password, decrypted)) { + return { authorized: false, error: 'Invalid password' } + } + + return { authorized: true } + } catch (error) { + logger.error(`[${requestId}] Error validating password:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + if (authType === 'email') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_email' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Email is required' } + } + + const { email, input } = parsedBody + + if (input && !email) { + return { authorized: false, error: 'auth_required_email' } + } + + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + if (isEmailAllowed(email, resource.allowedEmails)) { + return { authorized: false, error: 'otp_required' } + } + + return { authorized: false, error: 'Email not authorized' } + } catch (error) { + logger.error(`[${requestId}] Error validating email:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + if (authType === 'sso') { + try { + if (request.method !== 'GET' && !parsedBody) { + return { authorized: false, error: 'SSO authentication is required' } + } + + if (!request.sessionPresent) { + return { authorized: false, error: 'auth_required_sso' } + } + + const userEmail = request.sessionEmail + if (!userEmail) { + return { authorized: false, error: 'SSO session does not contain email' } + } + + if (isEmailAllowed(userEmail, resource.allowedEmails)) { + return { authorized: true, authenticatedEmail: normalizeEmail(userEmail) } + } + + return { authorized: false, error: 'Your email is not authorized to access this resource' } + } catch (error) { + logger.error(`[${requestId}] Error validating SSO:`, error) + return { authorized: false, error: 'SSO authentication error' } + } + } + + return { authorized: false, error: 'Unsupported authentication type' } +} diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 55c75aaf841..8ea840966cf 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -259,22 +259,29 @@ export function deploymentAuthCookieName(cookiePrefix: DeploymentAuthKind, id: s return `${cookiePrefix}_auth_${id}` } -/** Sets a signed, resource-bound authentication cookie for a deployment. */ -export async function setDeploymentAuthCookie({ - response, +/** Builds the existing deployment cookie without coupling domain authorization to an HTTP response. */ +export async function createDeploymentAuthCookie({ cookiePrefix, resource, verifiedEmail, -}: SetDeploymentAuthCookieParams): Promise { - response.cookies.set({ +}: Omit) { + return { name: deploymentAuthCookieName(cookiePrefix, resource.id), value: await generateAuthToken(resource, verifiedEmail), - httpOnly: true, + httpOnly: true as const, secure: !isDev, - sameSite: 'lax', + sameSite: 'lax' as const, path: '/', maxAge: DEPLOYMENT_AUTH_TOKEN_TTL_MS / 1000, - }) + } +} + +/** Sets a signed, resource-bound authentication cookie for a deployment. */ +export async function setDeploymentAuthCookie({ + response, + ...input +}: SetDeploymentAuthCookieParams): Promise { + response.cookies.set(await createDeploymentAuthCookie(input)) } /** diff --git a/apps/sim/lib/core/security/otp.ts b/apps/sim/lib/core/security/otp.ts index e8ac4636c9f..47435216274 100644 --- a/apps/sim/lib/core/security/otp.ts +++ b/apps/sim/lib/core/security/otp.ts @@ -152,7 +152,7 @@ export async function getOTP( const ATOMIC_INCREMENT_SCRIPT = ` local val = redis.call('GET', KEYS[1]) if not val then return nil end -local colon = val:find(':([^:]*$)') +local colon = val:find(':([^:]*)$') local otp, attempts if colon then otp = val:sub(1, colon - 1) diff --git a/apps/sim/lib/projects/files/__integration__/content-sharing.integration.ts b/apps/sim/lib/projects/files/__integration__/content-sharing.integration.ts new file mode 100644 index 00000000000..4dc2fbaf745 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/content-sharing.integration.ts @@ -0,0 +1,947 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { db, runOutsideTransactionContext } from '@sim/db' +import { + folder, + idempotencyKey, + member, + organization, + outboxEvent, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + publicShare, + subscription, + user, + workspace, + workspaceFileSecretProvenance, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) + +import * as sandboxTask from '@/lib/execution/sandbox/run-task' +import { + createProjectFile, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import * as shareManager from '@/lib/public-shares/manager' +import * as storage from '@/lib/uploads/core/storage-service' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const localStorageRoot = mkdtempSync(join(tmpdir(), 'sim-project-content-')) +setUploadDirServer(localStorageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + vi.restoreAllMocks() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@content.invalid`, + name: 'File content fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Content', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Content environment', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + return { ...ids, principal: createSessionPrincipal({ userId: editorId }) } +} + +function createInput(projectId: string, content = 'Project architecture') { + return { + projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content, + encoding: 'utf-8' as const, + exactName: true, + } +} + +describe('Public file shares against PostgreSQL and private storage', () => { + check( + 'public delivery rejects unknown and secret-bearing provenance before storage', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + for (const secretProvenance of [ + { status: 'unknown' as const }, + { + status: 'exact' as const, + entries: [{ encryptedValue: 'private-fixture', sourceUserId: f.editorId }], + }, + ]) { + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: `${generateId()}.md`, + secretProvenance, + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public fixture rejected') + const download = vi.spyOn(storage, 'downloadFile') + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(download).not.toHaveBeenCalled() + download.mockRestore() + } + } + ) + + check('non-SSO shares do not consult an unavailable session service', async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare } = await import('@/lib/public-shares/application') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const resolveSessionPrincipal = vi + .fn() + .mockRejectedValue(new Error('Session service unavailable')) + for (const authType of ['public', 'password', 'email'] as const) { + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType, + password: 'fixture-password', + allowedEmails: ['reader@shared.invalid'], + }, + }) + const result = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', resolveSessionPrincipal }, + }) + expect(result.authorized).toBe(authType === 'public') + } + expect(resolveSessionPrincipal).not.toHaveBeenCalled() + }) + + check( + 'public share configuration requires Project write and the canonical organization sharing policy', + async () => { + const { updateProjectFileShare, getProjectFileShare } = await import( + '@/lib/projects/files/application/shares' + ) + const f = await fixture() + const other = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const created = await updateProjectFileShare.execute({ principal: f.principal, input }) + expect(created.share.isActive).toBe(true) + const [stored] = await db + .select() + .from(publicShare) + .where(eq(publicShare.id, created.share.id)) + expect(stored).toMatchObject({ + entityType: 'project', + entityId: f.projectId, + workspaceId: null, + createdBy: f.editorId, + }) + await expect( + getProjectFileShare.execute({ + principal: other.principal, + input: { projectId: other.projectId, fileId: file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(eq(permissions.userId, f.editorId)) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await db + .update(permissions) + .set({ permissionType: 'admin' }) + .where(eq(permissions.userId, f.editorId)) + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + await db.insert(permissionGroup).values({ + id: generateId(), + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'No public sharing', + isDefault: true, + membershipMode: 'inherit', + config: { disablePublicFileSharing: true }, + }) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + const disabled = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, isActive: false }, + }) + expect(disabled.share).toMatchObject({ isActive: false, token: created.share.token }) + } + ) + + check( + 'public share policy includes each accessible active environment without a selected-environment bypass', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const secondId = generateId() + await insertWorkspaceFixture(db, { + id: secondId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + organizationId: f.organizationId, + workspaceMode: 'organization', + name: 'Restricted environment', + forkedFromWorkspaceId: f.workspaceId, + }) + try { + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Restricted sharing', + membershipMode: 'inherit', + config: { disablePublicFileSharing: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: secondId, + organizationId: f.organizationId, + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + await updateProjectFileShare.execute({ principal: f.principal, input }) + await db.insert(permissions).values({ + id: generateId(), + userId: f.editorId, + entityType: 'workspace', + entityId: secondId, + permissionType: 'read', + }) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + await db + .update(permissionGroup) + .set({ config: { allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + const allowed = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'a-valid-share-password' }, + }) + expect(allowed.share.authType).toBe('password') + const upsert = shareManager.upsertOwnedFileShare + const interleave = vi + .spyOn(shareManager, 'upsertOwnedFileShare') + .mockImplementationOnce(async (...args) => { + await runOutsideTransactionContext(() => + db + .update(publicShare) + .set({ authType: 'public', isActive: false }) + .where(eq(publicShare.id, allowed.share.id)) + ) + return upsert(...args) + }) + const reenabled = await updateProjectFileShare.execute({ principal: f.principal, input }) + expect(reenabled.share).toMatchObject({ authType: 'password', isActive: true }) + interleave.mockRestore() + } finally { + await deleteWorkspaceFixture(db, eq(workspace.id, secondId)) + } + } + ) + + check( + 'public share credentials preserve password cookies, verified email, and actual session SSO identity', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const { setDeploymentAuthCookie, deploymentAuthCookieName } = await import( + '@/lib/core/security/deployment' + ) + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'Shared architecture'), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'correct-password' }, + }) + const token = share.token + expect( + await authorizePublicFileShare({ token, credential: { method: 'GET' } }) + ).toMatchObject({ authorized: false, error: 'auth_required_password' }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'POST', password: 'wrong-password' }, + }) + ).toMatchObject({ authorized: false, error: 'Invalid password' }) + const accepted = await authorizePublicFileShare({ + token, + credential: { method: 'POST', password: 'correct-password' }, + }) + if (!accepted.authorized) throw new Error('Correct share password rejected') + expect((await readPublicFileShareContent({ grant: accepted.grant })).buffer.toString()).toBe( + 'Shared architecture' + ) + const [passwordShare] = await db + .select() + .from(publicShare) + .where(eq(publicShare.id, share.id)) + const response = NextResponse.json({}) + await setDeploymentAuthCookie({ response, cookiePrefix: 'file', resource: passwordShare }) + const cookies = Object.fromEntries( + response.cookies.getAll().map(({ name, value }) => [name, value]) + ) + expect( + await authorizePublicFileShare({ token, credential: { method: 'GET', cookies } }) + ).toMatchObject({ authorized: true }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'email', allowedEmails: ['viewer@shared.invalid'] }, + }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'POST', email: 'viewer@shared.invalid', cookies }, + }) + ).toMatchObject({ authorized: false, error: 'otp_required' }) + const [emailShare] = await db.select().from(publicShare).where(eq(publicShare.id, share.id)) + await setDeploymentAuthCookie({ + response, + cookiePrefix: 'file', + resource: emailShare, + verifiedEmail: 'viewer@shared.invalid', + }) + const emailCookie = response.cookies.get(deploymentAuthCookieName('file', share.id)) + if (!emailCookie) throw new Error('Email fixture token missing') + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', cookies: { [emailCookie.name]: emailCookie.value } }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: 'viewer@shared.invalid' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'sso', allowedEmails: [`${f.editorId}@content.invalid`] }, + }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', email: `${f.editorId}@content.invalid` }, + }) + ).toMatchObject({ authorized: false, error: 'auth_required_sso' }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', sessionPrincipal: f.principal }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: `${f.editorId}@content.invalid` }) + expect( + await authorizePublicFileShare({ + token, + credential: { + method: 'GET', + sessionPrincipal: createSessionPrincipal({ userId: f.ownerId }), + }, + }) + ).toMatchObject({ authorized: false }) + } + ) + + check( + 'public share password exchange binds its cookie to the current password and rejects wrong modes', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, authenticatePublicFileSharePassword } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const { share } = await updateProjectFileShare.execute({ principal: f.principal, input }) + await expect( + authenticatePublicFileSharePassword({ + token: share.token, + password: 'a-valid-share-password', + }) + ).rejects.toMatchObject({ code: 'validation' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'a-valid-share-password' }, + }) + expect( + await authenticatePublicFileSharePassword({ + token: share.token, + password: 'wrong-password', + }) + ).toMatchObject({ authorized: false, error: 'Invalid password' }) + const access = await authenticatePublicFileSharePassword({ + token: share.token, + password: 'a-valid-share-password', + }) + if (!access.authorized) throw new Error('Password exchange failed') + const cookies = { [access.cookie.name]: access.cookie.value } + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies }, + }) + ).toMatchObject({ authorized: true, authType: 'password' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, password: 'replacement-share-password' }, + }) + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies }, + }) + ).toMatchObject({ authorized: false, authType: 'password' }) + } + ) + + check( + 'public share OTP delivery and verification retain allowlists, code attempts, and current token policy', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, requestPublicFileShareOtp, verifyPublicFileShareOtp } = + await import('@/lib/public-shares/application') + const otpStore = await import('@/lib/core/security/otp') + const f = await fixture() + const email = `${generateId()}@shared.invalid` + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'email' as const, + allowedEmails: [email], + } + const { share } = await updateProjectFileShare.execute({ principal: f.principal, input }) + emailMailerMockFns.mockSendEmail.mockResolvedValue({ + success: true, + message: 'Local mail boundary accepted', + }) + await ( + await requestPublicFileShareOtp({ token: share.token, email: 'outsider@shared.invalid' }) + ).deliver() + expect(await otpStore.getOTP('file', share.id, 'outsider@shared.invalid')).toBeNull() + const failure = { authorized: false, status: 400, error: 'Invalid verification code' } + expect( + await verifyPublicFileShareOtp({ + token: share.token, + email: 'outsider@shared.invalid', + otp: '000000', + }) + ).toEqual(failure) + expect(await verifyPublicFileShareOtp({ token: share.token, email, otp: '000000' })).toEqual( + failure + ) + await (await requestPublicFileShareOtp({ token: share.token, email })).deliver() + const stored = await otpStore.getOTP('file', share.id, email) + if (!stored) throw new Error('Allowed email received no code') + const { otp } = otpStore.decodeOTPValue(stored) + expect( + await verifyPublicFileShareOtp({ token: share.token, email, otp: '000000' }) + ).toMatchObject({ authorized: false, status: 400 }) + const accepted = await verifyPublicFileShareOtp({ token: share.token, email, otp }) + if (!accepted.authorized) throw new Error('Valid verification code rejected') + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies: { [accepted.cookie.name]: accepted.cookie.value } }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: email }) + expect(await verifyPublicFileShareOtp({ token: share.token, email, otp })).toMatchObject({ + authorized: false, + status: 400, + }) + await otpStore.storeOTP('file', share.id, email, '123456') + const get = otpStore.getOTP + const spy = vi.spyOn(otpStore, 'getOTP').mockImplementation(async (...args) => { + const result = await get(...args) + await db + .update(publicShare) + .set({ allowedEmails: ['replacement@shared.invalid'] }) + .where(eq(publicShare.id, share.id)) + return result + }) + await expect( + verifyPublicFileShareOtp({ token: share.token, email, otp: '123456' }) + ).rejects.toMatchObject({ code: 'not_found' }) + spy.mockRestore() + await otpStore.deleteOTP('file', share.id, email) + } + ) + + check( + 'public share SSO eligibility never grants bytes and follows current active owner policy', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, getPublicFileShareSsoEligibility } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const email = `${f.editorId}@content.invalid` + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'sso', + allowedEmails: [email], + }, + }) + expect(await getPublicFileShareSsoEligibility({ token: share.token, email })).toEqual({ + allowed: true, + eligible: true, + }) + expect( + await getPublicFileShareSsoEligibility({ + token: share.token, + email: 'outsider@shared.invalid', + }) + ).toEqual({ allowed: true, eligible: false }) + expect( + await authorizePublicFileShare({ token: share.token, credential: { method: 'GET', email } }) + ).toMatchObject({ authorized: false, authType: 'sso' }) + await db.update(publicShare).set({ isActive: false }).where(eq(publicShare.id, share.id)) + await expect( + getPublicFileShareSsoEligibility({ token: share.token, email }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'public share Office artifacts require the current owner-qualified dependency cache without compilation', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'first input'), name: 'input.txt' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, `getFileBase64('${asset.file.id}')`), + name: 'report.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public artifact fixture rejected') + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'conflict', + }) + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue( + Buffer.from('PK\u0003\u0004artifact') + ) + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1024 }, + }) + expect((await readPublicFileShareContent({ grant: access.grant })).buffer).toEqual( + rendered.buffer + ) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: asset.file.id, + content: 'changed input', + encoding: 'utf-8', + }, + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'conflict', + }) + } + ) + + check( + 'public share revocation and password changes during object reads suppress downloaded bytes', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const { encryptSecret } = await import('@/lib/core/security/encryption') + for (const change of ['disabled', 'password', 'archived', 'provenance'] as const) { + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'password', + password: 'initial-password', + }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'POST', password: 'initial-password' }, + }) + if (!access.authorized) throw new Error('Initial credential rejected') + const download = storage.downloadFile + const spy = vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { + const result = await download(options) + if (options.key === file.key) { + if (change === 'disabled') + await db + .update(publicShare) + .set({ isActive: false }) + .where(eq(publicShare.id, share.id)) + if (change === 'password') + await db + .update(publicShare) + .set({ password: (await encryptSecret('replacement-password')).encrypted }) + .where(eq(publicShare.id, share.id)) + if (change === 'provenance') + await db + .update(workspaceFileSecretProvenance) + .set({ status: 'unknown' }) + .where(eq(workspaceFileSecretProvenance.fileId, file.id)) + if (change === 'archived') + await db + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(eq(workspaceFiles.id, file.id)) + } + return result + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: change === 'provenance' ? 'forbidden' : 'not_found', + }) + spy.mockRestore() + } + } + ) + + check( + 'public share grants cannot be cloned or reused after Project archive and preserve workspace links', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShare, readPublicFileShareContent } = + await import('@/lib/public-shares/application') + const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace/file-manager') + const { upsertFileShare } = await import('@/lib/public-shares/manager') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public share rejected') + expect(await readPublicFileShare({ grant: access.grant })).toMatchObject({ + file: { id: file.id, originalName: 'architecture.md' }, + }) + await expect( + readPublicFileShareContent({ grant: { ...access.grant } }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db.transaction(async (tx) => { + await tx + .update(workspace) + .set({ archivedAt: new Date() }) + .where(eq(workspace.id, f.workspaceId)) + await tx.update(project).set({ archivedAt: new Date() }).where(eq(project.id, f.projectId)) + }) + await expect(readPublicFileShare({ grant: access.grant })).rejects.toMatchObject({ + code: 'not_found', + }) + await db.transaction(async (tx) => { + await tx.update(project).set({ archivedAt: null }).where(eq(project.id, f.projectId)) + await tx.update(workspace).set({ archivedAt: null }).where(eq(workspace.id, f.workspaceId)) + }) + const legacy = await uploadWorkspaceFile( + f.workspaceId, + f.ownerId, + Buffer.from('Workspace link'), + 'legacy.txt', + 'text/plain' + ) + const legacyShare = await upsertFileShare({ + workspaceId: f.workspaceId, + fileId: legacy.id, + userId: f.ownerId, + isActive: true, + }) + const legacyAccess = await authorizePublicFileShare({ + token: legacyShare.token, + credential: { method: 'GET' }, + }) + if (!legacyAccess.authorized) throw new Error('Existing workspace share rejected') + expect( + (await readPublicFileShareContent({ grant: legacyAccess.grant })).buffer.toString() + ).toBe('Workspace link') + } + ) + + check( + 'public share page assets remain confined to explicit references in the canonical owner', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent, readPublicFileShareInline } = + await import('@/lib/public-shares/application') + const f = await fixture() + const other = await fixture() + const image = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9uoAAAAASUVORK5CYII=', + 'base64' + ) + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, image.toString('base64')), + encoding: 'base64', + name: 'diagram.png', + contentType: 'image/png', + }, + }) + const sibling = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'Private sibling'), name: 'private.txt' }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: createInput(other.projectId), + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Architecture\n---\n\n![Diagram](sim:file/${asset.file.id})` + ), + name: 'Architecture', + contentType: 'text/x-sim-page', + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public page rejected') + const rendered = await readPublicFileShareContent({ grant: access.grant }) + expect(rendered.contentType).toBe('text/html') + expect(rendered.buffer.toString()).toContain( + `data:image/png;base64,${image.toString('base64')}` + ) + expect( + (await readPublicFileShareInline({ grant: access.grant, fileId: asset.file.id })).buffer + ).toEqual(image) + for (const fileId of [sibling.file.id, foreign.file.id]) + await expect( + readPublicFileShareInline({ grant: access.grant, fileId }) + ).rejects.toMatchObject({ code: 'not_found' }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `---\ntitle: Architecture\n---\n\n![Foreign](sim:file/${foreign.file.id})`, + encoding: 'utf-8', + }, + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'not_found', + }) + } + ) +}) + +afterAll(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + for (const f of fixtures) { + await db.delete(idempotencyKey).where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%`}`) + await db + .delete(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + await db + .delete(publicShare) + .where(and(eq(publicShare.entityType, 'project'), eq(publicShare.entityId, f.projectId))) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(localStorageRoot, { recursive: true, force: true }) + const report = + process.env.PROJECT_FILE_CONTENT_REPORT_PATH ?? 'test-results/project-file-content.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + await db.$client.end() +}) diff --git a/apps/sim/lib/projects/files/application/index.ts b/apps/sim/lib/projects/files/application/index.ts index 60afb052ef8..4ea278da821 100644 --- a/apps/sim/lib/projects/files/application/index.ts +++ b/apps/sim/lib/projects/files/application/index.ts @@ -24,6 +24,7 @@ export { resolveProjectFileReference, } from './read' export { searchProjectFileContent } from './search' +export { getProjectFileShare, updateProjectFileShare } from './shares' export { abortProjectFileUploadSession, completeProjectFileUploadSession, diff --git a/apps/sim/lib/projects/files/application/shares.ts b/apps/sim/lib/projects/files/application/shares.ts new file mode 100644 index 00000000000..7e810e972fb --- /dev/null +++ b/apps/sim/lib/projects/files/application/shares.ts @@ -0,0 +1,138 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + type FileShareUpdate, + getOwnedFileShare, + ShareValidationError, + upsertOwnedFileShare, +} from '@/lib/public-shares/manager' + +interface ShareTarget extends ProjectFileTarget { + fileId: string +} +interface UpdateShareInput extends ShareTarget, Omit {} + +interface ProjectFileSharePolicy { + canPublish: boolean + allowedAuthTypes: NonNullable[] +} + +async function resolveProjectFileSharePolicy( + principal: Principal, + context: ProjectFileAuthorizationContext, + tx: DbTransaction +): Promise { + const userId = requirePrincipalSubjectUserId(principal) + const configs: Awaited>[] = [] + for (const workspaceId of context.visibleWorkspaceIds) { + configs.push( + await resolvePermissionGroupConfig(userId, workspaceId, context.organizationId, tx) + ) + } + if (configs.length === 0 && context.organizationId) + configs.push(await getUserPermissionConfigForOrganization(context.organizationId, tx)) + const modes = ['public', 'password', 'email', 'sso'] as const + return { + canPublish: configs.every( + (config) => !config || !CAPABILITY_RULES['file_share.publish'].deniedBy(config) + ), + allowedAuthTypes: modes.filter((mode) => + configs.every( + (config) => !config || !CAPABILITY_RULES['file_share.auth_mode'].deniedBy(config, mode) + ) + ), + } +} + +export const getProjectFileShare = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readShare, + ShareTarget, + { + share: Awaited> + policy: ProjectFileSharePolicy + capabilities: { canRead: true; canWrite: boolean } + } +>({ + operation: projectFileOperations.readShare, + async execute({ + principal, + input, + context, + tx, + }: { + principal: Principal + input: ShareTarget + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + return { + share: await getOwnedFileShare(tx, context.owner, input.fileId), + policy: await resolveProjectFileSharePolicy(principal, context, tx), + capabilities: { canRead: true, canWrite: context.canWrite }, + } + }, +}) + +export const updateProjectFileShare = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.updateShare, + UpdateShareInput, + { share: Awaited> } +>({ + operation: projectFileOperations.updateShare, + invalidatesFileList: ({ result }) => result.share !== null, + async execute({ + principal, + input, + context, + tx, + }: { + principal: Principal + input: UpdateShareInput + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + const existing = await getOwnedFileShare(tx, context.owner, input.fileId) + const effectiveAuthType = input.authType ?? existing?.authType ?? 'public' + if (input.isActive) { + const policy = await resolveProjectFileSharePolicy(principal, context, tx) + // permission-group-enforced: file_share.publish — the read projection and write gate share every applicable environment policy. + if (!policy.canPublish) refuseCapability('file_share.publish') + // permission-group-enforced: file_share.auth_mode — only the intersection of applicable modes may be published. + if (!policy.allowedAuthTypes.includes(effectiveAuthType)) + refuseCapability('file_share.auth_mode') + } + + try { + return { + share: await upsertOwnedFileShare(tx, context.owner, { + ...input, + authType: effectiveAuthType, + userId: requirePrincipalSubjectUserId(principal), + }), + } + } catch (error) { + if (error instanceof ShareValidationError) + throw new OrchestrationError('validation', error.message) + throw error + } + }, + projectAudit: ({ input, context }) => ({ + action: input.isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: input.fileId, + resourceName: context.file?.originalName, + description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for Project file`, + metadata: { projectId: context.projectId }, + }), +}) diff --git a/apps/sim/lib/public-shares/api/credentials.ts b/apps/sim/lib/public-shares/api/credentials.ts new file mode 100644 index 00000000000..87e44ff1ef6 --- /dev/null +++ b/apps/sim/lib/public-shares/api/credentials.ts @@ -0,0 +1,24 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { getSession } from '@/lib/auth' + +/** Authenticate the optional browser session without substituting the file creator for its caller. */ +export async function publicFileShareCredential( + cookies: readonly { name: string; value: string }[], + clientIp?: string | null +) { + return { + method: 'GET' as const, + cookies: Object.fromEntries(cookies.map(({ name, value }) => [name, value])), + async resolveSessionPrincipal() { + const session = await getSession() + let sessionPrincipal: SessionPrincipal | undefined + if (session?.user?.id) { + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + sessionPrincipal = { kind: 'session', userId: session.user.id, sessionId } + } + return sessionPrincipal + }, + clientIp, + } +} diff --git a/apps/sim/lib/public-shares/api/index.ts b/apps/sim/lib/public-shares/api/index.ts new file mode 100644 index 00000000000..d1f19dddef0 --- /dev/null +++ b/apps/sim/lib/public-shares/api/index.ts @@ -0,0 +1,7 @@ +export { publicFileShareCredential } from './credentials' +export { + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileErrorResponse, +} from './responses' +export { readPublicFileSocialMetadata } from './social-metadata' diff --git a/apps/sim/lib/public-shares/api/responses.ts b/apps/sim/lib/public-shares/api/responses.ts new file mode 100644 index 00000000000..e47a7cd2e4f --- /dev/null +++ b/apps/sim/lib/public-shares/api/responses.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' +import { + asOrchestrationError, + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' + +/** Keep the public cookie endpoints' existing error envelope and retry header. */ +export function publicFileAuthDenied(result: { + error?: string + status?: number + retryAfterMs?: number +}) { + const response = NextResponse.json( + { error: result.error ?? 'auth_required_password' }, + { + status: result.status ?? 401, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) + if (result.status === 429 && result.retryAfterMs !== undefined) + response.headers.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))) + return response +} + +function classifyPublicFileError(error: unknown, fallback: string) { + const classified = asOrchestrationError(error) + return { + code: classified?.code, + status: statusForOrchestrationError(classified?.code), + message: + classified?.code === 'not_found' + ? 'Not found' + : messageForOrchestrationError( + { errorCode: classified?.code, error: classified?.message }, + fallback + ), + } +} + +/** Conceal unavailable targets and driver failures while preserving classified domain failures. */ +export function publicFileErrorResponse(error: unknown, fallback: string) { + const result = classifyPublicFileError(error, fallback) + return NextResponse.json( + { error: result.message }, + { + status: result.status, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) +} + +/** Binary URLs retain the legacy file-error envelope; pending artifacts use their existing challenge. */ +export function publicFileBinaryErrorResponse(error: unknown, fallback: string) { + const result = classifyPublicFileError(error, fallback) + if (result.code === 'conflict') + return NextResponse.json( + { error: result.message }, + { + status: result.status, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) + const name = + result.code === 'not_found' + ? 'FileNotFoundError' + : result.code === 'payload_too_large' + ? 'PayloadSizeLimitError' + : 'Error' + return NextResponse.json( + { error: name, message: result.message }, + { + status: result.status, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) +} diff --git a/apps/sim/lib/public-shares/api/social-metadata.ts b/apps/sim/lib/public-shares/api/social-metadata.ts new file mode 100644 index 00000000000..e78becc6f38 --- /dev/null +++ b/apps/sim/lib/public-shares/api/social-metadata.ts @@ -0,0 +1,14 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { authorizePublicFileShare, readPublicFileShare } from '@/lib/public-shares/application' + +/** Social previews always use anonymous credentials, even when the viewer has an auth cookie. */ +export async function readPublicFileSocialMetadata(token: string) { + try { + const auth = await authorizePublicFileShare({ token, credential: { method: 'GET' } }) + if (!auth.authorized) return { protected: true as const } + return { protected: false as const, metadata: await readPublicFileShare({ grant: auth.grant }) } + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') return null + throw error + } +} diff --git a/apps/sim/lib/public-shares/application/authorization.ts b/apps/sim/lib/public-shares/application/authorization.ts new file mode 100644 index 00000000000..61fa67c7740 --- /dev/null +++ b/apps/sim/lib/public-shares/application/authorization.ts @@ -0,0 +1,259 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { publicShare, user, workspaceFiles } from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' +import { and, eq, isNull } from 'drizzle-orm' +import { isAccountBlocked } from '@/lib/auth/ban' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { deploymentAuthCookieName } from '@/lib/core/security/deployment' +import { + type DeploymentAuthResult, + validateDeploymentCredentials, +} from '@/lib/core/security/deployment-credentials' +import type { DbTransaction } from '@/lib/db/types' +import { + type PublicFileReadOperation, + publicFileOperations, +} from '@/lib/public-shares/application/operations' +import { loadPublicFileOwner } from '@/lib/public-shares/application/owner-adapters' +import { resolveFileOwner } from '@/lib/workspace-files/ownership' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +const grantIdentity = Symbol('verified-public-file-share') +export interface VerifiedPublicFileShareGrant { + readonly [grantIdentity]: true +} +interface PublicFileCredential { + method: 'GET' | 'POST' + password?: string + email?: string + cookies?: Readonly> + resolveSessionPrincipal?: () => Promise + sessionPrincipal?: Principal + clientIp?: string | null +} +interface SessionIdentity { + userId: string + email: string +} +interface GrantState { + token: string + policy: string + session?: SessionIdentity + authenticatedEmail?: string + expiresAt: number +} +const grants = new WeakMap() + +function unavailable(): never { + throw new OrchestrationError('not_found', 'File share not found') +} + +async function loadSnapshot(tx: DbTransaction, token: string) { + const [discovered] = await tx + .select({ share: publicShare, file: workspaceFiles }) + .from(publicShare) + .innerJoin(workspaceFiles, eq(publicShare.resourceId, workspaceFiles.id)) + .where(and(eq(publicShare.token, token), eq(publicShare.resourceType, 'file'))) + .limit(1) + if (!discovered) unavailable() + const owner = resolveFileOwner(discovered.file) + if ( + !owner || + discovered.share.entityType !== owner.entityType || + discovered.share.entityId !== owner.entityId + ) + unavailable() + const context = await loadPublicFileOwner(tx, owner) + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, discovered.file.id), + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + if (!file) unavailable() + const [share] = await tx + .select() + .from(publicShare) + .where( + and( + eq(publicShare.id, discovered.share.id), + eq(publicShare.token, token), + eq(publicShare.resourceType, 'file'), + eq(publicShare.resourceId, file.id), + eq(publicShare.entityType, context.owner.entityType), + eq(publicShare.entityId, context.owner.entityId), + eq(publicShare.isActive, true) + ) + ) + .for('share') + if ( + !share || + share.workspaceId !== (context.owner.entityType === 'workspace' ? context.owner.entityId : null) + ) + unavailable() + const [creator] = file.userId + ? await tx.select({ name: user.name }).from(user).where(eq(user.id, file.userId)).limit(1) + : [] + return { ...context, file, share, creatorName: creator?.name ?? null } +} +export type PublicFileShareSnapshot = Awaited> + +function policy(snapshot: PublicFileShareSnapshot) { + const { share, owner } = snapshot + return JSON.stringify([ + share.id, + share.token, + share.resourceId, + owner, + share.authType, + share.password, + share.allowedEmails, + ]) +} +async function loadSession( + tx: DbTransaction, + userId: string +): Promise { + const [row] = await tx + .select({ + id: user.id, + email: user.email, + banned: user.banned, + banExpires: user.banExpires, + suspendedAt: user.suspendedAt, + }) + .from(user) + .where(eq(user.id, userId)) + .for('share') + return row && !isAccountBlocked(row) ? { userId: row.id, email: row.email } : undefined +} + +/** Exchanges real credentials for an in-process, short-lived bearer grant bound to current sharing policy. */ +export async function authorizePublicFileShare({ + token, + credential, +}: { + token: string + credential: PublicFileCredential +}): Promise< + | (DeploymentAuthResult & { authorized: false; authType: string }) + | { + authorized: true + authType: string + grant: VerifiedPublicFileShareGrant + authenticatedEmail?: string + } +> { + try { + if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) unavailable() + const snapshot = await db.transaction((tx) => loadSnapshot(tx, token)) + const principal = + snapshot.share.authType === 'sso' + ? (credential.sessionPrincipal ?? (await credential.resolveSessionPrincipal?.())) + : undefined + const session = + principal?.kind === 'session' + ? await db.transaction((tx) => loadSession(tx, principal.userId)) + : undefined + const initial = { snapshot, session } + const result = await validateDeploymentCredentials( + generateShortId(), + initial.snapshot.share, + { + method: credential.method, + authToken: + credential.cookies?.[deploymentAuthCookieName('file', initial.snapshot.share.id)], + clientIp: credential.clientIp, + sessionPresent: Boolean(initial.session), + sessionEmail: initial.session?.email, + }, + credential.method === 'POST' + ? { password: credential.password, email: credential.email } + : undefined, + 'file' + ) + if (!result.authorized) + return { ...result, authorized: false, authType: initial.snapshot.share.authType } + const state: GrantState = { + token, + policy: policy(initial.snapshot), + session: initial.session, + authenticatedEmail: result.authenticatedEmail, + expiresAt: Date.now() + 60_000, + } + await db.transaction((tx) => validateState(tx, state)) + const grant: VerifiedPublicFileShareGrant = Object.freeze({ [grantIdentity]: true as const }) + grants.set(grant, state) + return { + authorized: true, + grant, + authType: initial.snapshot.share.authType, + authenticatedEmail: result.authenticatedEmail, + } + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +async function validateState(tx: DbTransaction, state: GrantState) { + if (state.expiresAt <= Date.now()) unavailable() + const snapshot = await loadSnapshot(tx, state.token) + if (policy(snapshot) !== state.policy) unavailable() + if (state.session) { + const session = await loadSession(tx, state.session.userId) + if (!session || session.email !== state.session.email) unavailable() + } + return snapshot +} + +/** A credential challenge authorizes only verification of this live token's declared authentication mode. */ +export async function preparePublicFileShareChallenge( + token: string, + authType: 'password' | 'email' | 'sso' +) { + if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) unavailable() + const initial = await db.transaction((tx) => loadSnapshot(tx, token)) + if (initial.share.authType !== authType) { + const message = + authType === 'sso' + ? 'This file is not configured for SSO' + : `This file does not use ${authType} authentication` + throw new OrchestrationError('validation', message) + } + const state: GrantState = { token, policy: policy(initial), expiresAt: Date.now() + 60_000 } + return Object.freeze({ + shareId: initial.share.id, + async withCurrentPolicy( + execute: (snapshot: PublicFileShareSnapshot) => Promise | T + ): Promise { + try { + return await db.transaction(async (tx) => execute(await validateState(tx, state))) + } catch (error) { + throw asOrchestrationError(error) ?? error + } + }, + }) +} + +/** Every bearer read rechecks current token, owner, file lifetime, and verified session policy. */ +export async function withPublicFileShareGrant( + grant: VerifiedPublicFileShareGrant, + operation: PublicFileReadOperation, + execute: (tx: DbTransaction, snapshot: PublicFileShareSnapshot) => Promise +): Promise { + try { + if (!Object.values(publicFileOperations).some((registered) => registered === operation)) + throw new Error('Unregistered public file operation') + const state = grants.get(grant) + if (!state) unavailable() + return await db.transaction(async (tx) => execute(tx, await validateState(tx, state))) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} diff --git a/apps/sim/lib/public-shares/application/credentials.ts b/apps/sim/lib/public-shares/application/credentials.ts new file mode 100644 index 00000000000..e8036c4e50e --- /dev/null +++ b/apps/sim/lib/public-shares/application/credentials.ts @@ -0,0 +1,165 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { normalizeEmail } from '@sim/utils/string' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' +import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { createDeploymentAuthCookie, isEmailAllowed } from '@/lib/core/security/deployment' +import { + decodeOTPValue, + deleteOTP, + generateOTP, + getOTP, + incrementOTPAttempts, + MAX_OTP_ATTEMPTS, + OTP_EMAIL_RATE_LIMIT, + OTP_RESOURCE_RATE_LIMIT, + storeOTP, +} from '@/lib/core/security/otp' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { + authorizePublicFileShare, + preparePublicFileShareChallenge, +} from '@/lib/public-shares/application/authorization' + +const logger = createLogger('PublicFileShareCredentials') +const rateLimiter = new RateLimiter() +const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} +const OTP_FAILURE = { authorized: false as const, error: 'Invalid verification code', status: 400 } + +/** Exchanges a verified password for a cookie bound to the share's current password slot. */ +export async function authenticatePublicFileSharePassword({ + token, + password, + clientIp, +}: { + token: string + password: string + clientIp?: string | null +}) { + const challenge = await preparePublicFileShareChallenge(token, 'password') + const result = await authorizePublicFileShare({ + token, + credential: { method: 'POST', password, clientIp }, + }) + if (!result.authorized) return result + const resource = await challenge.withCurrentPolicy(({ share }) => share) + const cookie = await createDeploymentAuthCookie({ cookiePrefix: 'file', resource }) + await challenge.withCurrentPolicy(() => undefined) + return { authorized: true as const, authType: 'password' as const, cookie } +} + +/** Schedules code delivery without revealing whether the supplied email is on the allow-list. */ +export async function requestPublicFileShareOtp({ + token, + email, +}: { + token: string + email: string +}) { + const normalizedEmail = normalizeEmail(email) + const challenge = await preparePublicFileShareChallenge(token, 'email') + return { + async deliver(): Promise { + const allowed = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizedEmail, share.allowedEmails) + ) + if (!allowed) return + const resourceLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:resource:${challenge.shareId}`, + OTP_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceLimit.allowed) return + const emailLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:email:${challenge.shareId}:${normalizedEmail}`, + OTP_EMAIL_RATE_LIMIT, + { failClosed: true } + ) + if (!emailLimit.allowed) return + const otp = generateOTP() + await storeOTP('file', challenge.shareId, normalizedEmail, otp) + const html = await renderOTPEmail(otp, 'email-verification', 'a shared file') + await challenge.withCurrentPolicy(() => undefined) + const result = await sendEmail({ + to: normalizedEmail, + subject: getOtpSubject('a shared file'), + html, + }) + if (!result.success) + logger.error('Failed to send public file verification code', { + shareId: challenge.shareId, + message: result.message, + }) + }, + } +} + +/** Consumes the existing email code and issues a cookie only while its original sharing policy remains current. */ +export async function verifyPublicFileShareOtp({ + token, + email, + otp, +}: { + token: string + email: string + otp: string +}) { + const normalizedEmail = normalizeEmail(email) + const challenge = await preparePublicFileShareChallenge(token, 'email') + const allowed = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizedEmail, share.allowedEmails) + ) + if (!allowed) return OTP_FAILURE + const value = await getOTP('file', challenge.shareId, normalizedEmail) + await challenge.withCurrentPolicy(() => undefined) + if (!value) return OTP_FAILURE + const stored = decodeOTPValue(value) + if (stored.attempts >= MAX_OTP_ATTEMPTS) { + await deleteOTP('file', challenge.shareId, normalizedEmail) + return OTP_FAILURE + } + if (!safeCompare(stored.otp, otp)) { + await incrementOTPAttempts('file', challenge.shareId, normalizedEmail, value) + return OTP_FAILURE + } + await deleteOTP('file', challenge.shareId, normalizedEmail) + const resource = await challenge.withCurrentPolicy(({ share }) => share) + const cookie = await createDeploymentAuthCookie({ + cookiePrefix: 'file', + resource, + verifiedEmail: normalizedEmail, + }) + await challenge.withCurrentPolicy(() => undefined) + return { authorized: true as const, authType: 'email' as const, cookie } +} + +/** Checks allow-list eligibility for the SSO redirect without granting access or asserting session identity. */ +export async function getPublicFileShareSsoEligibility({ + token, + email, +}: { + token: string + email: string +}) { + const challenge = await preparePublicFileShareChallenge(token, 'sso') + const limit = await rateLimiter.checkRateLimitDirect( + `file-sso:resource:${challenge.shareId}`, + SSO_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!limit.allowed) + return { + allowed: false as const, + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limit.retryAfterMs ?? SSO_RESOURCE_RATE_LIMIT.refillIntervalMs, + } + const eligible = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizeEmail(email), share.allowedEmails) + ) + return { allowed: true as const, eligible } +} diff --git a/apps/sim/lib/public-shares/application/index.ts b/apps/sim/lib/public-shares/application/index.ts new file mode 100644 index 00000000000..b722ee4c0d5 --- /dev/null +++ b/apps/sim/lib/public-shares/application/index.ts @@ -0,0 +1,13 @@ +export { authorizePublicFileShare } from './authorization' +export { + authenticatePublicFileSharePassword, + getPublicFileShareSsoEligibility, + requestPublicFileShareOtp, + verifyPublicFileShareOtp, +} from './credentials' +export { + checkPublicFileShareContent, + readPublicFileShare, + readPublicFileShareContent, + readPublicFileShareInline, +} from './read' diff --git a/apps/sim/lib/public-shares/application/operations.ts b/apps/sim/lib/public-shares/application/operations.ts new file mode 100644 index 00000000000..740ab3d2938 --- /dev/null +++ b/apps/sim/lib/public-shares/application/operations.ts @@ -0,0 +1,35 @@ +import { + type ApplicationOperation, + assertOperationCapability, +} from '@/lib/core/application/operation' + +interface PublicFileOperation extends ApplicationOperation { + readonly authority: 'verified_public_file_share' +} +function definePublicFileOperation(operation: O): O { + assertOperationCapability(operation) + return Object.freeze(operation) +} + +export const publicFileOperations = { + // permission-group-exempt: verified share credentials authorize bearer reads; member publishing enforces sharing policy. + readMetadata: definePublicFileOperation({ + id: 'public_files.read_metadata', + authority: 'verified_public_file_share', + capability: 'none', + }), + // permission-group-exempt: verified share credentials authorize bearer reads; member publishing enforces sharing policy. + readContent: definePublicFileOperation({ + id: 'public_files.read_content', + authority: 'verified_public_file_share', + capability: 'none', + }), + // permission-group-exempt: the verified share grant covers only current same-owner images referenced by its file. + readInline: definePublicFileOperation({ + id: 'public_files.read_inline', + authority: 'verified_public_file_share', + capability: 'none', + }), +} as const +export type PublicFileReadOperation = + (typeof publicFileOperations)[keyof typeof publicFileOperations] diff --git a/apps/sim/lib/public-shares/application/owner-adapters.ts b/apps/sim/lib/public-shares/application/owner-adapters.ts new file mode 100644 index 00000000000..6715bf34422 --- /dev/null +++ b/apps/sim/lib/public-shares/application/owner-adapters.ts @@ -0,0 +1,112 @@ +import { project, type WorkspaceFileRow, workspace } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { lockProjectShared, lockWorkspaceProject } from '@/lib/projects/membership' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' +import { + type E2BDocFormat, + fileDocumentCacheIdentity, + resolveServableDoc, +} from '@/lib/uploads/documents/compile' +import { loadCompiledDoc } from '@/lib/uploads/documents/compiled-store' +import { fileDocumentInputIdentity } from '@/lib/uploads/documents/input-identity' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { EditableFileOwner, FileOwner } from '@/lib/workspace-files/ownership' + +interface PublicFileOwnerContext { + owner: EditableFileOwner + organizationId: string | null + displayName: string + workspaceId: string | null +} +interface DocumentRead { + owner: EditableFileOwner + source: Buffer + sourceMime: string + fileName: string + format: E2BDocFormat + dependencies: readonly WorkspaceFileRow[] +} +interface PublicFileOwnerAdapter { + load(tx: DbTransaction, entityId: string): Promise + storageContext: 'workspace' | 'project' + pageOptions(entityId: string): { workspaceId?: string; projectId?: string } + readCompiled(input: DocumentRead): Promise<{ buffer: Buffer; contentType: string }> +} + +const adapters: FileOwnerAdapters = { + workspace: { + async load(tx, entityId) { + await lockWorkspaceProject(tx, entityId) + const [row] = await tx.select().from(workspace).where(eq(workspace.id, entityId)).for('share') + if (!row || row.archivedAt) throw new OrchestrationError('not_found', 'File share not found') + return { + owner: { entityType: 'workspace', entityId }, + organizationId: row.organizationId, + displayName: row.name, + workspaceId: entityId, + } + }, + storageContext: 'workspace', + pageOptions: (workspaceId) => ({ workspaceId }), + async readCompiled({ owner, source, sourceMime, fileName }) { + const artifact = await resolveServableDoc(owner.entityId, source, fileName, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + sourceMime, + }) + if (artifact.kind !== 'artifact') + throw new OrchestrationError( + 'conflict', + 'This document is still being prepared. Please try again shortly.' + ) + return artifact + }, + }, + project: { + async load(tx, entityId) { + await requireProjectApiEnabled() + await lockProjectShared(tx, entityId) + const [row] = await tx.select().from(project).where(eq(project.id, entityId)).for('share') + if (!row || row.archivedAt) throw new OrchestrationError('not_found', 'File share not found') + return { + owner: { entityType: 'project', entityId }, + organizationId: row.organizationId, + displayName: row.name, + workspaceId: null, + } + }, + storageContext: 'project', + pageOptions: (projectId) => ({ projectId }), + async readCompiled({ owner, source, sourceMime, format, dependencies }) { + const buffer = await loadCompiledDoc( + owner, + source.toString('utf8'), + format.ext, + fileDocumentCacheIdentity( + source.toString('utf8'), + format, + sourceMime, + fileDocumentInputIdentity(owner, dependencies) + ), + { maxBytes: MAX_BUFFERED_TRANSFER_BYTES } + ) + if (!buffer) + throw new OrchestrationError( + 'conflict', + 'This document is still being prepared. Please try again shortly.' + ) + return { buffer, contentType: format.contentType } + }, + }, +} + +/** Bearer policy requires a live canonical owner; it never substitutes a member or creator identity. */ +export async function loadPublicFileOwner(tx: DbTransaction, owner: FileOwner) { + const adapter = requireFileOwnerAdapter(adapters, owner) + return { ...(await adapter.load(tx, owner.entityId)), adapter } +} diff --git a/apps/sim/lib/public-shares/application/read.ts b/apps/sim/lib/public-shares/application/read.ts new file mode 100644 index 00000000000..9ff4ef28c7a --- /dev/null +++ b/apps/sim/lib/public-shares/application/read.ts @@ -0,0 +1,435 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { + asOrchestrationError, + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import type { DbTransaction } from '@/lib/db/types' +import { + type PublicFileShareSnapshot, + type VerifiedPublicFileShareGrant, + withPublicFileShareGrant, +} from '@/lib/public-shares/application/authorization' +import { + type PublicFileReadOperation, + publicFileOperations, +} from '@/lib/public-shares/application/operations' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { downloadFile, headObject } from '@/lib/uploads/core/storage-service' +import { + collectReferencedFileIds, + getDocumentSourceLanguage, + getE2BDocFormat, + isCompiledDocumentBuffer, +} from '@/lib/uploads/documents/compile' +import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' +import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file' +import { sniffImageContentType } from '@/lib/uploads/utils/validation' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { renderSimPageDocument } from '@/lib/workspace-files/page-document' +import { + collectSimPageFileReferences, + inlineSimPageImages, +} from '@/lib/workspace-files/page-document.server' + +interface PublicReadInput { + grant: VerifiedPublicFileShareGrant + request?: OrchestrationRequestContext +} +interface PublicManifest { + snapshot: PublicFileShareSnapshot + dependencies: WorkspaceFileRow[] + pageHtml?: string +} + +function present(snapshot: PublicFileShareSnapshot) { + const { file } = snapshot + return { + owner: snapshot.owner, + ownerName: snapshot.creatorName, + workspaceName: snapshot.displayName, + file: { + id: file.id, + originalName: file.originalName, + contentType: file.contentType, + sizeBytes: file.sizeBytes, + uploadedBy: file.userId, + updatedAt: file.updatedAt, + }, + } +} +function requireRevision(current: WorkspaceFileRow, expected: WorkspaceFileRow) { + if ( + current.id !== expected.id || + current.key !== expected.key || + current.contentUpdatedAt.getTime() !== expected.contentUpdatedAt.getTime() + ) + throw new OrchestrationError('conflict', 'Shared file changed during the read') +} +function requirePublicBytes(provenance: WorkspaceFileSecretProvenance) { + if ( + provenance.status === 'unknown' || + (provenance.status === 'exact' && provenance.entries.length > 0) + ) + throw new OrchestrationError('forbidden', 'File cannot be shared safely') +} +async function admitPublicFiles(tx: DbTransaction, files: readonly WorkspaceFileRow[]) { + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, files) + const provenance = mergeWorkspaceFileSecretProvenance(...evidence.values()) + requirePublicBytes(provenance) + return provenance +} +async function dependencies( + tx: DbTransaction, + snapshot: PublicFileShareSnapshot, + ids: readonly string[] +) { + if (ids.length > 500) + throw new OrchestrationError('payload_too_large', 'Too many document inputs') + if (ids.length === 0) return [] + const rows = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(snapshot.owner), + inArray(workspaceFiles.id, [...ids]), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .for('share') + if (rows.length !== ids.length) + throw new OrchestrationError('not_found', 'Document input not found') + await admitPublicFiles(tx, rows) + return rows +} +async function finish( + grant: VerifiedPublicFileShareGrant, + manifest: PublicManifest, + operation: PublicFileReadOperation, + buffer: Buffer, + contentType: string, + request?: OrchestrationRequestContext, + servedFile: WorkspaceFileRow = manifest.snapshot.file +) { + assertKnownSizeWithinLimit(buffer.length, MAX_BUFFERED_TRANSFER_BYTES, 'shared file') + const result = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, manifest.snapshot.file) + const current = await dependencies( + tx, + snapshot, + manifest.dependencies.map((file) => file.id) + ) + const expected = new Map(manifest.dependencies.map((file) => [file.id, file])) + for (const file of current) { + const prior = expected.get(file.id) + if (!prior) throw new OrchestrationError('not_found', 'Document input not found') + requireRevision(file, prior) + } + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [ + snapshot.file, + ...current, + ]) + requirePublicBytes(mergeWorkspaceFileSecretProvenance(...evidence.values())) + return { + ...present(snapshot), + buffer, + contentType, + servedFileName: servedFile.originalName, + secretProvenance: mergeWorkspaceFileSecretProvenance(...evidence.values()), + organizationId: snapshot.organizationId, + } + }) + await reportWorkspaceFileDelivery(result.secretProvenance, servedFile.id) + recordAudit({ + actorId: null, + workspaceId: manifest.snapshot.workspaceId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: servedFile.id, + resourceName: servedFile.originalName, + description: `Public share download of "${servedFile.originalName}"`, + request, + metadata: { + organizationId: result.organizationId, + operation: operation.id, + sharedDocumentId: result.file.id, + sharedByUserId: manifest.snapshot.file.userId, + access: 'public_share', + anonymous: true, + owner: result.owner, + bytes: buffer.length, + }, + }) + return result +} + +/** Metadata is disclosed only after the current token policy has produced a verified bearer grant. */ +export function readPublicFileShare({ grant }: PublicReadInput) { + return withPublicFileShareGrant(grant, publicFileOperations.readMetadata, async (_tx, snapshot) => + present(snapshot) + ) +} + +/** Checks cached representation availability without rendering or recording a download. */ +export async function checkPublicFileShareContent( + grant: VerifiedPublicFileShareGrant +): Promise { + const operation = publicFileOperations.readContent + try { + const initial = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + await admitPublicFiles(tx, [snapshot.file]) + return snapshot + }) + const stored = await headObject(initial.file.key, initial.adapter.storageContext) + if (!stored) throw new OrchestrationError('not_found', 'File not found') + assertKnownSizeWithinLimit(stored.size, MAX_BUFFERED_TRANSFER_BYTES, 'File') + const format = await getE2BDocFormat(initial.file.originalName) + let inputs: WorkspaceFileRow[] = [] + if (format) { + const source = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + if (!isCompiledDocumentBuffer(initial.file.originalName, source)) { + const text = source.toString('utf8') + const ids = [ + ...new Set( + collectReferencedFileIds( + text, + isDocSandboxEnabled + ? getDocumentSourceLanguage(text, format, initial.file.contentType) + : 'javascript' + ) + ), + ] + inputs = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, initial.file) + return dependencies(tx, snapshot, ids) + }) + const artifact = await initial.adapter.readCompiled({ + owner: initial.owner, + source, + sourceMime: initial.file.contentType, + fileName: initial.file.originalName, + format, + dependencies: inputs, + }) + assertKnownSizeWithinLimit( + artifact.buffer.length, + MAX_BUFFERED_TRANSFER_BYTES, + 'shared file' + ) + } + } + await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, initial.file) + const current = await dependencies( + tx, + snapshot, + inputs.map((file) => file.id) + ) + await admitPublicFiles(tx, [snapshot.file, ...current]) + for (let index = 0; index < current.length; index++) { + requireRevision(current[index], inputs[index]) + } + }) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +/** A public read never executes generated document code; it serves current cached artifacts only. */ +export async function readPublicFileShareContent({ + grant, + preview, + request, +}: PublicReadInput & { preview?: boolean }) { + const operation = publicFileOperations.readContent + try { + const initial = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + await admitPublicFiles(tx, [snapshot.file]) + return snapshot + }) + const raw = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + const format = await getE2BDocFormat(initial.file.originalName) + const generated = Boolean(format && !isCompiledDocumentBuffer(initial.file.originalName, raw)) + const source = + generated || + initial.file.contentType === SIM_PAGE_CONTENT_TYPE || + initial.file.originalName.toLowerCase().endsWith('.html') + ? raw.toString('utf8') + : '' + const manifest = await withPublicFileShareGrant( + grant, + operation, + async (tx, snapshot): Promise => { + requireRevision(snapshot.file, initial.file) + const pageHtml = + (snapshot.file.contentType === SIM_PAGE_CONTENT_TYPE || + snapshot.file.originalName.toLowerCase().endsWith('.html')) && + isSimPageSource(source) + ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + : undefined + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + if ( + references.some( + (ref) => + ref.projectId && + (snapshot.owner.entityType !== 'project' || ref.projectId !== snapshot.owner.entityId) + ) + ) + throw new OrchestrationError('not_found', 'Document input not found') + const ids = [ + ...new Set( + pageHtml + ? references.map((ref) => ref.fileId) + : generated && format + ? collectReferencedFileIds( + source, + isDocSandboxEnabled + ? getDocumentSourceLanguage(source, format, initial.file.contentType) + : 'javascript' + ) + : [] + ), + ] + return { snapshot, pageHtml, dependencies: await dependencies(tx, snapshot, ids) } + } + ) + let buffer = raw + let contentType = resolveEffectiveMimeType(undefined, initial.file.originalName) + if (manifest.pageHtml) { + const byId = new Map(manifest.dependencies.map((file) => [file.id, file])) + const page = await inlineSimPageImages( + manifest.pageHtml, + async ({ fileId }, maxBytes) => { + const file = byId.get(fileId) + if (!file) throw new OrchestrationError('not_found', 'Document input not found') + const bytes = await downloadFile({ + key: file.key, + context: initial.adapter.storageContext, + maxBytes, + }) + const mime = sniffImageContentType(bytes) + if (!mime) throw new OrchestrationError('not_found', 'Embedded image not found') + return { bytes, contentType: mime, identity: file.id } + }, + { strict: true } + ) + buffer = Buffer.from(page.html) + contentType = 'text/html' + } else if (generated && format) { + ;({ buffer, contentType } = await initial.adapter.readCompiled({ + owner: initial.owner, + source: raw, + sourceMime: initial.file.contentType, + fileName: initial.file.originalName, + format, + dependencies: manifest.dependencies, + })) + } else if (preview) { + const image = await resolveServableImageBytes(raw, initial.file.key, { owner: initial.owner }) + if (image) ({ buffer, contentType } = image) + } + return await finish(grant, manifest, operation, buffer, contentType, request) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +/** A share grants only current image embeds in its own document and canonical owner. */ +export async function readPublicFileShareInline({ + grant, + fileId, + key, + request, +}: PublicReadInput & { fileId?: string; key?: string }) { + const operation = publicFileOperations.readInline + try { + if (Boolean(fileId) === Boolean(key)) + throw new OrchestrationError('validation', 'Select one image reference') + const initial = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + await admitPublicFiles(tx, [snapshot.file]) + return snapshot + }) + const raw = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: 10 * 1024 * 1024, + }).catch((error: unknown) => { + if (isPayloadSizeLimitError(error)) + throw new OrchestrationError('not_found', 'Embedded image not found') + throw error + }) + const source = raw.toString('utf8') + const manifest = await withPublicFileShareGrant( + grant, + operation, + async (tx, snapshot): Promise => { + requireRevision(snapshot.file, initial.file) + const pageHtml = isSimPageSource(source) + ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + : undefined + const reference = fileId ? { fileId } : key ? { key } : null + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + const embedded = + reference && + (pageHtml + ? references.some( + (ref) => + ref.fileId === fileId && + (!ref.projectId || + (snapshot.owner.entityType === 'project' && + ref.projectId === snapshot.owner.entityId)) + ) + : hasEmbeddedFileRef(source, reference, snapshot.owner)) + if (!embedded) throw new OrchestrationError('not_found', 'Embedded image not found') + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(snapshot.owner), + isNull(workspaceFiles.deletedAt), + fileId ? eq(workspaceFiles.id, fileId) : eq(workspaceFiles.key, key ?? '') + ) + ) + .for('share') + if (!file) throw new OrchestrationError('not_found', 'Embedded image not found') + await admitPublicFiles(tx, [snapshot.file, file]) + return { snapshot, dependencies: [file] } + } + ) + const target = manifest.dependencies[0] + if (!target) throw new OrchestrationError('not_found', 'Embedded image not found') + const buffer = await downloadFile({ + key: target.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + const contentType = sniffImageContentType(buffer) + if (!contentType) throw new OrchestrationError('not_found', 'Embedded image not found') + return await finish(grant, manifest, operation, buffer, contentType, request, target) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} diff --git a/apps/sim/lib/public-shares/manager.ts b/apps/sim/lib/public-shares/manager.ts index 52940830088..8fbf4b52b15 100644 --- a/apps/sim/lib/public-shares/manager.ts +++ b/apps/sim/lib/public-shares/manager.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { publicShare, user, type WorkspaceFileRow, workspace, workspaceFiles } from '@sim/db/schema' +import { publicShare, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -9,10 +9,12 @@ import type { ShareRecord, shareResourceTypeSchema, } from '@/lib/api/contracts/public-shares' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { DbTransaction } from '@/lib/db/types' import { lockWorkspaceProject } from '@/lib/projects/membership' -import { resolveFileOwner } from '@/lib/workspace-files/ownership' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' const logger = createLogger('PublicShareManager') @@ -141,8 +143,7 @@ export async function getWorkspaceShares( return result } -interface UpsertFileShareInput { - workspaceId: string +export interface FileShareUpdate { fileId: string userId: string isActive: boolean @@ -167,162 +168,146 @@ interface UpsertFileShareInput { * Disabling (going Private) always succeeds and preserves the stored config so a * later re-enable restores it. Validation failures throw {@link ShareValidationError}. */ -export async function upsertFileShare({ - workspaceId, - fileId, - userId, - isActive, - authType, - password, - allowedEmails, - token, -}: UpsertFileShareInput): Promise { +export async function upsertFileShare( + input: FileShareUpdate & { workspaceId: string } +): Promise { return db.transaction(async (tx) => { - await lockWorkspaceProject(tx, workspaceId) - const [file] = await tx - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.id, fileId), - fileOwnerCondition({ entityType: 'workspace', entityId: workspaceId }), - isNull(workspaceFiles.deletedAt) - ) - ) - .for('update') - if (!file) throw new ShareValidationError('File not found') - const [existing] = await tx - .select() - .from(publicShare) - .where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId))) - .limit(1) - - const finalAuthType: ShareAuthType = - authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public' - const existingAllowedEmails = Array.isArray(existing?.allowedEmails) - ? (existing.allowedEmails as string[]) - : [] - - // Disabling preserves the stored config (and skips validation) so turning - // sharing off always succeeds; only enabling validates the chosen auth mode. - let finalPassword: string | null = existing?.password ?? null - let finalAllowedEmails: string[] = existingAllowedEmails - if (isActive) { - if (finalAuthType === 'password') { - if (password) { - finalPassword = (await encryptSecret(password)).encrypted - } else if (existing?.password) { - finalPassword = existing.password - } else { - throw new ShareValidationError('Password is required for password-protected shares') - } - finalAllowedEmails = [] - } else if (finalAuthType === 'email' || finalAuthType === 'sso') { - finalAllowedEmails = allowedEmails ?? existingAllowedEmails - if (finalAllowedEmails.length === 0) { - throw new ShareValidationError( - 'At least one allowed email is required for email/SSO shares' - ) - } - finalPassword = null - } else { - finalPassword = null - finalAllowedEmails = [] - } - } - - const [row] = await tx - .insert(publicShare) - .values({ - id: generateId(), - resourceType: 'file', - resourceId: fileId, - workspaceId, - entityType: 'workspace', - entityId: workspaceId, - createdBy: userId, - token: token ?? generateShortId(), - isActive, - authType: finalAuthType, - password: finalPassword, - allowedEmails: finalAllowedEmails, - }) - .onConflictDoUpdate({ - target: [publicShare.resourceType, publicShare.resourceId], - set: { - isActive, - authType: finalAuthType, - password: finalPassword, - allowedEmails: finalAllowedEmails, - updatedAt: new Date(), - }, - }) - .returning() - - logger.info('Upserted file share', { - fileId, - workspaceId, - isActive, - authType: finalAuthType, - }) - return mapShareRecord(row) + await lockWorkspaceProject(tx, input.workspaceId) + return upsertOwnedFileShare(tx, { entityType: 'workspace', entityId: input.workspaceId }, input) }) } -/** - * Resolve a public token to its active share and the underlying (non-deleted) - * file. Returns null if the token is unknown, the share is inactive, or the file - * is gone. The caller treats null as a 404 — the existence of a file is never - * leaked through this path. - */ -export interface ResolvedShare { - share: PublicShareRow & { workspaceId: string } - file: WorkspaceFileRow & { workspaceId: string } - /** Owning workspace name, for provenance on the public page. */ - workspaceName: string | null - /** Display name of the file's uploader. */ - ownerName: string | null -} - -export async function resolveActiveShareByToken(token: string): Promise { - const [row] = await db - .select({ - share: publicShare, - file: workspaceFiles, - workspaceName: workspace.name, - ownerName: user.name, - }) +/** Reads a share only within its canonical file owner; authorization belongs to the caller. */ +export async function getOwnedFileShare( + tx: DbTransaction, + owner: EditableFileOwner, + fileId: string +): Promise { + const [row] = await tx + .select() .from(publicShare) - .innerJoin(workspaceFiles, eq(workspaceFiles.id, publicShare.resourceId)) - .leftJoin(workspace, eq(workspace.id, workspaceFiles.workspaceId)) - .leftJoin(user, eq(user.id, workspaceFiles.userId)) .where( and( - eq(publicShare.token, token), - eq(publicShare.isActive, true), eq(publicShare.resourceType, 'file'), + eq(publicShare.resourceId, fileId), + eq(publicShare.entityType, owner.entityType), + eq(publicShare.entityId, owner.entityId) + ) + ) + .limit(1) + return row ? mapShareRecord(row) : null +} + +/** Reuses share configuration and token lifetime for every registered editable owner. */ +export async function upsertOwnedFileShare( + tx: DbTransaction, + owner: EditableFileOwner, + { fileId, userId, isActive, authType, password, allowedEmails, token }: FileShareUpdate +): Promise { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + eq(workspaceFiles.id, fileId), isNull(workspaceFiles.deletedAt) ) ) + .for('update') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const [existing] = await tx + .select() + .from(publicShare) + .where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId))) .limit(1) - if (!row || !row.file.workspaceId) return null - const owner = resolveFileOwner(row.file) if ( - owner?.entityType !== 'workspace' || - owner.entityId !== row.file.workspaceId || - row.share.workspaceId !== owner.entityId || - !( - (row.share.entityType === null && row.share.entityId === null) || - (row.share.entityType === 'workspace' && row.share.entityId === owner.entityId) - ) + existing && + (existing.entityType !== owner.entityType || existing.entityId !== owner.entityId) ) - return null + throw new OrchestrationError('not_found', 'File share not found') - return { - share: { ...row.share, workspaceId: owner.entityId }, - file: { ...row.file, workspaceId: owner.entityId }, - workspaceName: row.workspaceName, - ownerName: row.ownerName, + const finalAuthType: ShareAuthType = + authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public' + const existingAllowedEmails = Array.isArray(existing?.allowedEmails) + ? (existing.allowedEmails as string[]) + : [] + + // Disabling preserves the stored config (and skips validation) so turning + // sharing off always succeeds; only enabling validates the chosen auth mode. + let finalPassword: string | null = existing?.password ?? null + let finalAllowedEmails: string[] = existingAllowedEmails + if (isActive) { + if (!['public', 'password', 'email', 'sso'].includes(finalAuthType)) + throw new ShareValidationError('Invalid share authentication mode') + if (password !== undefined && (password.length < 15 || password.length > 1024)) + throw new ShareValidationError('Password must be between 15 and 1024 characters') + if ( + allowedEmails && + (allowedEmails.length > 200 || + allowedEmails.some((email) => email.length < 1 || email.length > 320)) + ) + throw new ShareValidationError('Invalid allowed email list') + if (!existing && token !== undefined && !/^[A-Za-z0-9_-]{16,64}$/.test(token)) + throw new ShareValidationError('Invalid share token') + if (finalAuthType === 'password') { + if (password) { + finalPassword = (await encryptSecret(password)).encrypted + } else if (existing?.password) { + finalPassword = existing.password + } else { + throw new ShareValidationError('Password is required for password-protected shares') + } + finalAllowedEmails = [] + } else if (finalAuthType === 'email' || finalAuthType === 'sso') { + finalAllowedEmails = allowedEmails ?? existingAllowedEmails + if (finalAllowedEmails.length === 0) { + throw new ShareValidationError( + 'At least one allowed email is required for email/SSO shares' + ) + } + finalPassword = null + } else { + finalPassword = null + finalAllowedEmails = [] + } } + + const [row] = await tx + .insert(publicShare) + .values({ + id: generateId(), + resourceType: 'file', + resourceId: fileId, + workspaceId: owner.entityType === 'workspace' ? owner.entityId : null, + entityType: owner.entityType, + entityId: owner.entityId, + createdBy: userId, + token: token ?? generateShortId(), + isActive, + authType: finalAuthType, + password: finalPassword, + allowedEmails: finalAllowedEmails, + }) + .onConflictDoUpdate({ + target: [publicShare.resourceType, publicShare.resourceId], + set: { + isActive, + authType: finalAuthType, + password: finalPassword, + allowedEmails: finalAllowedEmails, + updatedAt: new Date(), + }, + }) + .returning() + + logger.info('Upserted file share', { + fileId, + owner, + isActive, + authType: finalAuthType, + }) + return mapShareRecord(row) } diff --git a/apps/sim/scripts/test-project-file-inline-source-e2e.ts b/apps/sim/scripts/test-project-file-inline-source-e2e.ts new file mode 100644 index 00000000000..8a5f1aa1329 --- /dev/null +++ b/apps/sim/scripts/test-project-file-inline-source-e2e.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { toArray, toRecord } from '@sim/utils/object' +import { createPublicFileContentSource } from '@/hooks/use-file-content-source' + +const base = new URL(required(process.env.PROJECT_INLINE_SOURCE_BASE_URL)) +assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)) +const fixturePath = required(process.env.PROJECT_INLINE_SOURCE_FIXTURE_PATH) +const reportPath = required(process.env.PROJECT_INLINE_SOURCE_REPORT_PATH) +const fixture = toRecord(JSON.parse(await readFile(fixturePath, 'utf8'))) +const account = toRecord( + JSON.parse(await readFile(join(dirname(fixturePath), 'owner-account.json'), 'utf8')) +) +const cookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const doc = toRecord(toArray(fixture.files).find((value) => toRecord(value).owner === 'project')) +const foreign = toRecord(fixture.wrongOwner) +const owner = { entityType: 'project', entityId: required(fixture.projectId) } as const +const token = required(doc.token) +const contentPath = `/api/files/public/${token}/content` +const ownPath = `/api/projects/${owner.entityId}/files/${required(doc.imageId)}/content` +const foreignPath = `/api/projects/${required(foreign.projectId)}/files/${required(foreign.fileId)}/content` +const publicSource = createPublicFileContentSource(token, contentPath, owner) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] + +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required fixture or environment field missing') + return value +} +async function request(path: string, authenticated = false) { + // boundary-raw-fetch: this proof pairs the actual browser source adapter with real private and public image delivery. + return fetch(new URL(path, base), { + headers: { ...(authenticated ? { Cookie: cookie } : {}), 'X-Forwarded-For': '203.0.113.242' }, + signal: AbortSignal.timeout(90_000), + }) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } +} + +await check( + 'The foreign image exists and the signed-in viewer can read its private URL', + async () => { + const response = await request(foreignPath, true) + assert.equal(response.status, 200) + assert.equal(response.headers.get('content-type'), 'image/png') + assert.ok((await response.arrayBuffer()).byteLength > 0) + } +) +await check( + 'Private Project inline caching distinguishes immutable keys from live file IDs', + async () => { + const metadata = await request( + `/api/projects/${owner.entityId}/files/${required(doc.imageId)}`, + true + ) + assert.equal(metadata.status, 200) + const key = required(toRecord(toRecord(await metadata.json()).file).key) + for (const query of [ + `key=${encodeURIComponent(key)}`, + `fileId=${encodeURIComponent(required(doc.imageId))}`, + ]) { + const response = await request(`/api/projects/${owner.entityId}/files/inline?${query}`, true) + assert.equal(response.status, 200) + assert.equal( + response.headers.get('cache-control'), + query.startsWith('key=') + ? 'private, max-age=31536000, immutable' + : 'private, no-cache, must-revalidate' + ) + assert.ok((await response.arrayBuffer()).byteLength > 0) + } + } +) +await check( + 'The document token grants its own image and conceals the foreign Project image', + async () => { + assert.equal( + (await request(`/api/files/public/${token}/inline?fileId=${required(doc.imageId)}`)).status, + 200 + ) + assert.equal( + (await request(`/api/files/public/${token}/inline?fileId=${required(foreign.fileId)}`)) + .status, + 404 + ) + const response = await request(contentPath) + assert.equal(response.status, 200) + assert.ok( + (await response.text()).includes(foreignPath), + 'Fixture must actually embed the foreign private URL' + ) + } +) +await check( + 'Canonical and private same-owner image references stay on the public token pipeline', + async () => { + const expected = `/api/files/public/${token}/inline?fileId=${required(doc.imageId)}` + assert.equal(publicSource.resolveImageSrc(ownPath), expected) + assert.equal( + publicSource.resolveImageSrc(`sim:file/${required(doc.imageId)}?project=${owner.entityId}`), + expected + ) + } +) +await check('Public sources omit foreign-owner private image URLs', async () => { + assert.equal(publicSource.resolveImageSrc(foreignPath), undefined) +}) + +await mkdir(dirname(reportPath), { recursive: true }) +await writeFile( + reportPath, + JSON.stringify( + { + boundary: + 'Real browser source adapter plus actual HTTP; native browser screenshot remains a separate check', + checks, + }, + null, + 2 + ) +) +process.exitCode = checks.some((check) => !check.passed) ? 1 : 0 diff --git a/apps/sim/scripts/test-project-file-public-e2e.ts b/apps/sim/scripts/test-project-file-public-e2e.ts new file mode 100644 index 00000000000..12db759cc67 --- /dev/null +++ b/apps/sim/scripts/test-project-file-public-e2e.ts @@ -0,0 +1,508 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, truncate, unlink, writeFile } from 'node:fs/promises' +import { dirname, join, resolve, sep } from 'node:path' +import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId, generateShortId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import Redis from 'ioredis' +import postgres from 'postgres' +import sharp from 'sharp' +import { encryptSecret } from '@/lib/core/security/encryption' +import { getStorageProvider } from '@/lib/uploads/config' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { fileDocumentCacheIdentity, getE2BDocFormat } from '@/lib/uploads/documents/compile' +import { compiledArtifactKey, storeCompiledDoc } from '@/lib/uploads/documents/compiled-store' + +/** Real public wire/cookie proof; SQL and Redis only seed disposable sharing and OTP fixtures. */ +const base = new URL(required(process.env.PROJECT_FILE_PUBLIC_BASE_URL)) +const databaseUrl = new URL(required(process.env.DATABASE_URL)) +const redisUrl = new URL(required(process.env.REDIS_URL)) +for (const url of [base, databaseUrl, redisUrl]) + assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), 'Local runtime required') +assertDisposableTestDatabaseUrl(databaseUrl.toString()) +assert.equal(base.protocol, 'http:') +const reportPath = required(process.env.PROJECT_FILE_PUBLIC_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_PUBLIC_FIXTURE_DIR) +const keepFixtures = process.env.PROJECT_FILE_PUBLIC_KEEP_FIXTURES === '1' +const sql = postgres(databaseUrl.toString(), { max: 2 }) +const redis = new Redis(redisUrl.toString(), { maxRetriesPerRequest: 1 }) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +const files: { owner: 'workspace' | 'project'; ownerId: string; id: string }[] = [] +const shares: { id: string; token: string; fileId: string; owner: 'workspace' | 'project' }[] = [] +const otpKeys: string[] = [] +const proofIp = '203.0.113.241' +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required environment or fixture field missing') + return value +} +async function fixture(name: string) { + return toRecord(JSON.parse(await readFile(join(fixtureDir, name), 'utf8'))) +} +const account = await fixture('owner-account.json') +const sessionCookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const projectId = required(toRecord((await fixture('http-project-fixture.json')).project).id) +const workspaceId = required(process.env.PROJECT_FILE_PUBLIC_WORKSPACE_ID) +const [actor] = await sql<{ email: string }[]>` + SELECT u.email FROM project p JOIN "user" u ON u.id = p.owner_id WHERE p.id = ${projectId} +` +assert.ok(actor, 'Project owner fixture missing') +const allowedEmail = actor.email +const password = 'Local-public-file-proof-password' +const encryptedPassword = (await encryptSecret(password)).encrypted +const image = await sharp({ + create: { width: 96, height: 96, channels: 4, background: '#1976d2' }, +}) + .png() + .toBuffer() + +async function request( + path: string, + options: { method?: string; body?: Record; cookie?: string } = {} +) { + const headers = new Headers({ + Origin: base.origin, + 'Content-Type': 'application/json', + 'X-Forwarded-For': proofIp, + }) + if (options.cookie) headers.set('Cookie', options.cookie) + // boundary-raw-fetch: this proof exercises actual public binary, RSC, and credential HTTP surfaces. + const response = await fetch(new URL(path, base), { + method: options.method ?? 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: AbortSignal.timeout(90_000), + redirect: 'manual', + }) + const buffer = Buffer.from(await response.arrayBuffer()) + return { status: response.status, headers: response.headers, buffer, text: buffer.toString() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +function assertBinaryError(response: Awaited>, status: 404 | 413) { + const body = json(response, status) + assert.deepEqual(Object.keys(body).sort(), ['error', 'message']) + assert.equal(body.error, status === 404 ? 'FileNotFoundError' : 'PayloadSizeLimitError') + if (status === 404) assert.equal(body.message, 'Not found') + else assert.ok(typeof body.message === 'string' && body.message.includes('104857600')) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +async function withEmptyRateBucket(key: string, run: () => Promise) { + const redisKey = `ratelimit:tb:${key}` + const original = await redis.hgetall(redisKey) + const ttl = await redis.pttl(redisKey) + await redis.hset(redisKey, { tokens: '0', lastRefillAt: String(Date.now()) }) + await redis.expire(redisKey, 120) + try { + await run() + } finally { + await redis.del(redisKey) + if (Object.keys(original).length) { + await redis.hset(redisKey, original) + if (ttl > 0) await redis.pexpire(redisKey, ttl) + } + } +} +async function expectRateLimit(path: string, options?: Parameters[1]) { + const response = await request(path, options) + assert.equal(response.status, 429) + assert.ok(Number(response.headers.get('retry-after')) > 0, 'Retry-After missing') +} +async function withSparseFile(fileId: string, bytes: number, run: () => Promise) { + assert.equal(getStorageProvider(), 'Local', 'Sparse boundary proof requires local storage') + const [row] = await sql<{ key: string }[]>`SELECT key FROM workspace_files WHERE id=${fileId}` + assert.ok(row && files.some((file) => file.id === fileId), 'Only this run fixture may be resized') + const path = resolve(UPLOAD_DIR_SERVER, row.key) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + const original = await readFile(path) + try { + await truncate(path, bytes) + await run() + } finally { + await writeFile(path, original) + } +} +async function createFile( + owner: 'workspace' | 'project', + name: string, + content: string, + type: string, + encoding = 'utf-8' +) { + const ownerId = owner === 'project' ? projectId : workspaceId + const file = toRecord( + json( + await request(`/api/${owner === 'project' ? 'projects' : 'workspaces'}/${ownerId}/files`, { + method: 'POST', + cookie: sessionCookie, + body: { name, content, contentType: type, encoding }, + }), + 201 + ).file + ) + files.push({ owner, ownerId, id: required(file.id) }) + return file +} +async function shareFile(owner: 'workspace' | 'project', fileId: string) { + const id = generateId() + const token = generateShortId() + await sql`INSERT INTO public_share (id, resource_type, resource_id, entity_type, entity_id, workspace_id, created_by, token) + SELECT ${id}, 'file', f.id, CASE WHEN f.project_id IS NOT NULL THEN 'project' ELSE 'workspace' END, + coalesce(f.project_id, f.workspace_id), f.workspace_id, f.user_id, ${token} + FROM workspace_files f WHERE f.id = ${fileId}` + const share = { id, token, fileId, owner } + shares.push(share) + return share +} +function cookieFrom(response: Awaited>, shareId: string) { + const value = response.headers.get('set-cookie') ?? '' + assert.ok(value.startsWith(`file_auth_${shareId}=`), 'Expected resource-bound auth cookie') + for (const attribute of ['HttpOnly', 'SameSite=Lax', 'Path=/', 'Max-Age=86400']) + assert.ok( + value.toLowerCase().includes(attribute.toLowerCase()), + `Missing ${attribute} cookie attribute` + ) + return value.split(';')[0] +} +try { + const browserFixtures = [] + for (const owner of ['workspace', 'project'] as const) { + const suffix = generateShortId(8) + const photo = await createFile( + owner, + `public-photo-${suffix}.png`, + image.toString('base64'), + 'image/png', + 'base64' + ) + const reference = + owner === 'project' + ? `sim:file/${required(photo.id)}?project=${projectId}` + : `/api/files/view/${required(photo.id)}` + const privateReference = + owner === 'project' + ? `/api/projects/${projectId}/files/${required(photo.id)}/content` + : `/api/files/view/${required(photo.id)}` + const source = `# Public owner proof\n\n![Canonical image](${reference})\n\n![Private URL image](${privateReference})\n` + const doc = await createFile(owner, `public-document-${suffix}.md`, source, 'text/markdown') + const share = await shareFile(owner, required(doc.id)) + browserFixtures.push({ + owner, + fileId: doc.id, + imageId: photo.id, + token: share.token, + source, + privateUrl: `/workspace/${workspaceId}/files/${required(doc.id)}${owner === 'project' ? `?owner=project&projectId=${projectId}` : ''}`, + publicUrl: `/f/${share.token}`, + }) + const path = `/api/files/public/${share.token}` + await check(`${owner}: public metadata and source preserve safe wire and headers`, async () => { + const metadata = json(await request(path)) + assert.equal(metadata.name, doc.name) + assert.deepEqual(Object.keys(metadata).sort(), [ + 'name', + 'ownerName', + 'size', + 'token', + 'type', + 'workspaceName', + ]) + assert.ok( + typeof metadata.workspaceName === 'string' && metadata.workspaceName, + 'Canonical owner display name missing' + ) + const bytes = await request(`${path}/content`) + assert.equal(bytes.status, 200) + assert.equal(bytes.text, source) + assert.equal(bytes.headers.get('cache-control'), 'private, no-cache, must-revalidate') + assert.equal(bytes.headers.get('x-content-type-options'), 'nosniff') + assert.match(bytes.headers.get('content-disposition') ?? '', /filename=/) + }) + await check( + `${owner}: HEAD rejects a missing stored source without generating content`, + async () => { + assert.equal(getStorageProvider(), 'Local', 'Missing-object fixture requires local storage') + const file = await createFile( + owner, + `head-missing-${suffix}.txt`, + 'head source', + 'text/plain' + ) + const share = await shareFile(owner, required(file.id)) + const contentPath = `/api/files/public/${share.token}/content` + assert.equal((await request(contentPath, { method: 'HEAD' })).status, 200) + const [stored] = await sql< + { key: string }[] + >`SELECT key FROM workspace_files WHERE id = ${required(file.id)}` + const path = resolve(UPLOAD_DIR_SERVER, required(stored?.key)) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + const bytes = await readFile(path) + try { + await unlink(path) + const missing = await request(contentPath, { method: 'HEAD' }) + assert.equal(missing.status, 404) + assert.equal(missing.buffer.length, 0) + } finally { + await writeFile(path, bytes) + } + } + ) + await check(`${owner}: inline image requires the current document grant`, async () => { + const bytes = await request(`${path}/inline?fileId=${required(photo.id)}`) + assert.equal(bytes.status, 200) + assert.ok(bytes.buffer.equals(image), 'Inline bytes differ from fixture') + assert.equal(bytes.headers.get('content-type'), 'image/png') + const head = await request(`${path}/inline?fileId=${required(photo.id)}`, { method: 'HEAD' }) + assert.equal(head.status, 200) + assert.equal(head.buffer.length, 0) + assertBinaryError(await request(`${path}/inline?fileId=unreferenced`), 404) + assert.equal( + (await request(`${path}/inline?fileId=${required(photo.id)}&key=other`)).status, + 400 + ) + }) + await check( + `${owner}: source and inline scan enforce independent real byte ceilings`, + async () => { + await withSparseFile(required(doc.id), 100 * 1024 * 1024 + 1, async () => { + assertBinaryError(await request(`${path}/content`), 413) + assertBinaryError(await request(`${path}/inline?fileId=${required(photo.id)}`), 404) + }) + await withSparseFile(required(photo.id), 100 * 1024 * 1024 + 1, async () => { + assertBinaryError(await request(`${path}/inline?fileId=${required(photo.id)}`), 413) + }) + } + ) + await check( + `${owner}: a small generated source cannot bypass the compiled output ceiling`, + async () => { + assert.equal(getStorageProvider(), 'Local', 'Artifact fixture requires local storage') + const source = `// Public output ceiling fixture ${suffix}` + const generated = await createFile( + owner, + `public-output-${suffix}.pptx`, + source, + 'text/x-pptxgenjs' + ) + const artifactShare = await shareFile(owner, required(generated.id)) + const contentPath = `/api/files/public/${artifactShare.token}/content` + const unavailable = await request(contentPath) + assert.equal( + json(unavailable, 409).error, + 'This document is still being prepared. Please try again shortly.' + ) + const format = await getE2BDocFormat('output.pptx') + assert.ok(format) + const cacheIdentity = + owner === 'project' + ? fileDocumentCacheIdentity(source, format, 'text/x-pptxgenjs') + : undefined + const key = compiledArtifactKey( + { entityType: owner, entityId: owner === 'workspace' ? workspaceId : projectId }, + source, + 'pptx', + cacheIdentity + ) + await storeCompiledDoc( + { entityType: owner, entityId: owner === 'workspace' ? workspaceId : projectId }, + source, + 'pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + Buffer.from('PK\u0003\u0004output-fixture'), + cacheIdentity + ) + const path = resolve(UPLOAD_DIR_SERVER, required(key)) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + try { + await truncate(path, 100 * 1024 * 1024 + 1) + assertBinaryError(await request(contentPath), 413) + } finally { + await unlink(path) + } + } + ) + await check(`${owner}: public read budgets precede target lookup`, async () => { + const unknown = `/api/files/public/${generateShortId()}` + for (const [scope, target] of [ + ['metadata', unknown], + ['content', `${unknown}/content`], + ['inline', `${unknown}/inline?fileId=unknown`], + ]) + await withEmptyRateBucket(`public-file:${scope}:${proofIp}`, () => expectRateLimit(target)) + }) + await check(`${owner}: public mode cannot mint a password cookie`, async () => { + const response = await request(path, { method: 'POST', body: { password } }) + json(response, 400) + assert.equal(response.headers.get('set-cookie'), null) + }) + await sql`UPDATE public_share SET auth_type='password', password=${encryptedPassword} WHERE id=${share.id}` + await check( + `${owner}: protected RSC hides filename while password cookie unlocks bytes`, + async () => { + const page = await request(`/f/${share.token}`) + assert.equal(page.status, 200) + assert.equal( + page.text.includes(required(doc.name)), + false, + 'Protected page disclosed filename' + ) + assert.equal(json(await request(path), 401).error, 'auth_required_password') + assert.equal( + json(await request(path, { method: 'POST', body: { password: 'wrong' } }), 401).error, + 'Invalid password' + ) + for (const rateKey of [ + `file-password:ip:${share.id}:${proofIp}`, + `file-password:resource:${share.id}`, + ]) + await withEmptyRateBucket(rateKey, () => + expectRateLimit(path, { method: 'POST', body: { password } }) + ) + const accepted = await request(path, { method: 'POST', body: { password } }) + assert.equal(json(accepted).authType, 'password') + const cookie = cookieFrom(accepted, share.id) + assert.equal((await request(`${path}/content`, { cookie })).text, source) + await sql`UPDATE public_share SET password=${(await encryptSecret(`${password}-changed`)).encrypted} WHERE id=${share.id}` + assert.equal((await request(`${path}/content`, { cookie })).status, 401) + } + ) + await sql`UPDATE public_share SET auth_type='email', password=NULL, allowed_emails=${sql.json([allowedEmail])} WHERE id=${share.id}` + await check( + `${owner}: email OTP denial, consumption, and cookie policy stay current`, + async () => { + const rejectedEmail = 'public-proof-rejected@example.invalid' + await withEmptyRateBucket(`file-otp:ip:${proofIp}`, () => + expectRateLimit(`${path}/otp`, { method: 'POST', body: { email: rejectedEmail } }) + ) + assert.equal( + json(await request(`${path}/otp`, { method: 'POST', body: { email: rejectedEmail } })) + .message, + 'Verification code sent' + ) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: rejectedEmail, otp: '123456' }, + }) + ).status, + 400 + ) + const key = `otp:file:${allowedEmail}:${share.id}` + otpKeys.push(key) + await redis.set(key, '123456:0', 'EX', 900) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '000000' }, + }) + ).status, + 400 + ) + const accepted = await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '123456' }, + }) + assert.equal(json(accepted).authType, 'email') + const cookie = cookieFrom(accepted, share.id) + assert.equal(await redis.get(key), null, 'Successful verification did not consume OTP') + await redis.set(key, '123456:4', 'EX', 900) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '000000' }, + }) + ).status, + 400 + ) + assert.equal(await redis.get(key), null, 'Attempt exhaustion did not consume OTP') + assert.equal((await request(path, { cookie })).status, 200) + await sql`UPDATE public_share SET allowed_emails='[]'::json WHERE id=${share.id}` + assert.equal((await request(path, { cookie })).status, 401) + } + ) + await sql`UPDATE public_share SET auth_type='sso', allowed_emails=${sql.json([allowedEmail])} WHERE id=${share.id}` + await check(`${owner}: SSO eligibility alone never grants file access`, async () => { + for (const rateKey of [`file-sso:ip:${proofIp}`, `file-sso:resource:${share.id}`]) + await withEmptyRateBucket(rateKey, () => + expectRateLimit(`${path}/sso`, { method: 'POST', body: { email: allowedEmail } }) + ) + assert.equal( + json(await request(`${path}/sso`, { method: 'POST', body: { email: allowedEmail } })) + .eligible, + true + ) + assert.equal( + json( + await request(`${path}/sso`, { + method: 'POST', + body: { email: 'rejected@example.invalid' }, + }) + ).eligible, + false + ) + assert.equal(json(await request(path), 401).error, 'auth_required_sso') + assert.equal((await request(path, { cookie: sessionCookie })).status, 200) + }) + await sql`UPDATE public_share SET auth_type='public', password=NULL, allowed_emails='[]'::json WHERE id=${share.id}` + } + await writeFile( + join(fixtureDir, 'public-browser-fixtures.json'), + JSON.stringify({ projectId, workspaceId, files: browserFixtures }, null, 2), + { mode: 0o600 } + ) +} catch (error) { + checks.push({ + name: 'fixture setup', + passed: false, + durationMs: 0, + error: getErrorMessage(error), + }) +} finally { + if (!keepFixtures) { + for (const share of shares) + await sql`UPDATE public_share SET is_active=false WHERE id=${share.id}` + for (const owner of ['workspace', 'project'] as const) { + const owned = files.filter((file) => file.owner === owner) + const first = owned[0] + if (!first) continue + await check(`${owner}: fixture cleanup archives only this run's files`, async () => { + const prefix = `/api/${owner === 'project' ? 'projects' : 'workspaces'}/${first.ownerId}/files` + json( + await request(`${prefix}/${owner === 'project' ? 'archive' : 'bulk-archive'}`, { + method: 'POST', + cookie: sessionCookie, + body: { fileIds: owned.map((file) => file.id) }, + }) + ) + }) + } + } + for (const key of otpKeys) await redis.del(key) + await redis.quit() + await sql.end() + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +process.exitCode = checks.some((entry) => !entry.passed) ? 1 : 0 diff --git a/apps/sim/scripts/test-project-file-sharing-e2e.ts b/apps/sim/scripts/test-project-file-sharing-e2e.ts new file mode 100644 index 00000000000..55327a01cb7 --- /dev/null +++ b/apps/sim/scripts/test-project-file-sharing-e2e.ts @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + subscription, + user, + workspace, +} from '@sim/db/schema' +import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import { and, eq, inArray } from 'drizzle-orm' + +/** Actual internal sharing policy and public token proof using disposable local fixture owners. */ +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required fixture or environment value missing') + return value +} +const base = new URL(required(process.env.PROJECT_FILE_SHARING_BASE_URL)) +const database = new URL(required(process.env.DATABASE_URL)) +for (const url of [base, database]) + assert.ok(['localhost', '127.0.0.1', '[::1]'].includes(url.hostname), 'Local runtime required') +assertDisposableTestDatabaseUrl(database.toString()) +const reportPath = required(process.env.PROJECT_FILE_SHARING_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_SHARING_FIXTURE_DIR) +const account = toRecord(JSON.parse(await readFile(join(fixtureDir, 'owner-account.json'), 'utf8'))) +const cookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const parent = toRecord( + JSON.parse(await readFile(join(fixtureDir, 'http-project-fixture.json'), 'utf8')) +) +const [actor] = await db + .select({ id: project.ownerId }) + .from(project) + .where(eq(project.id, required(toRecord(parent.project).id))) +assert.ok(actor) +const [existingMembership] = await db + .select({ organizationId: member.organizationId, name: organization.name }) + .from(member) + .innerJoin(organization, eq(organization.id, member.organizationId)) + .where(eq(member.userId, actor.id)) +if (existingMembership) + assert.equal( + existingMembership.name, + 'Sharing policy proof', + 'Use an unassociated disposable account' + ) +const [existingOwner] = existingMembership + ? await db + .select({ id: member.userId }) + .from(member) + .where( + and(eq(member.organizationId, existingMembership.organizationId), eq(member.role, 'owner')) + ) + : [] +const ownerId = existingOwner?.id ?? generateId() +const organizationId = existingMembership?.organizationId ?? generateId() +const workspaceId = generateId() +const otherWorkspaceId = generateId() +const groupId = generateId() +if (!existingMembership) { + await db.insert(user).values({ + id: ownerId, + name: 'Sharing proof owner', + email: `${ownerId}@sharing.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(organization).values({ + id: organizationId, + name: 'Sharing policy proof', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values( + [ownerId, actor.id].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) +} +await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Share policy environment', + ownerId, + organizationId, + billedAccountUserId: ownerId, + workspaceMode: 'organization', +}) +await insertWorkspaceFixture(db, { + id: otherWorkspaceId, + name: 'Restricted sharing environment', + ownerId, + organizationId, + billedAccountUserId: ownerId, + workspaceMode: 'organization', + forkedFromWorkspaceId: workspaceId, +}) +const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) +assert.ok(binding) +const projectId = binding.projectId +await db.insert(permissions).values( + [workspaceId, otherWorkspaceId].map((entityId) => ({ + id: generateId(), + userId: actor.id, + entityType: 'workspace', + entityId, + permissionType: 'admin' as const, + })) +) +await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: organizationId, + status: 'active', + metadata: {}, +}) +await db.insert(permissionGroup).values({ + id: groupId, + organizationId, + createdBy: ownerId, + name: `Sharing policy proof ${groupId}`, + membershipMode: 'inherit', + config: {}, +}) +await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: otherWorkspaceId, + organizationId, +}) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +async function request(path: string, method = 'GET', body?: object, authenticated = true) { + // boundary-raw-fetch: E2E exercises the running app's internal JSON and anonymous binary surfaces. + const response = await fetch(new URL(path, base), { + method, + headers: { + Origin: base.origin, + 'Content-Type': 'application/json', + ...(authenticated ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(90_000), + redirect: 'manual', + }) + return { status: response.status, text: await response.text() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +async function check(name: string, run: () => Promise) { + const started = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify( + { checks, fixture: { projectId, workspaceId, otherWorkspaceId, organizationId } }, + null, + 2 + ) + ) +} +const content = 'Shared Project architecture fixture' +const projectFile = toRecord( + json( + await request(`/api/projects/${projectId}/files`, 'POST', { + name: 'sharing-project.txt', + content, + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file +) +const workspaceFile = toRecord( + json( + await request(`/api/workspaces/${workspaceId}/files`, 'POST', { + name: 'sharing-environment.txt', + content: 'Environment only fixture', + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file +) +const fileId = required(projectFile.id) +const workspaceFileId = required(workspaceFile.id) +const path = `/api/projects/${projectId}/files/${fileId}/share` +let token = '' +await check('Project sharing read projects current effective policy', async () => { + const result = json(await request(path)) + assert.equal(result.share, null) + assert.deepEqual(result.policy, { + canPublish: true, + allowedAuthTypes: ['public', 'password', 'email', 'sso'], + }) + assert.deepEqual(result.capabilities, { canRead: true, canWrite: true }) +}) +await check('Read-only Project access cannot publish or revoke', async () => { + const ownPermissions = and( + eq(permissions.userId, actor.id), + inArray(permissions.entityId, [workspaceId, otherWorkspaceId]) + ) + await db.update(permissions).set({ permissionType: 'read' }).where(ownPermissions) + try { + assert.equal(toRecord(json(await request(path)).capabilities).canWrite, false) + assert.equal((await request(path, 'PUT', { isActive: true })).status, 403) + assert.equal((await request(path, 'PUT', { isActive: false })).status, 403) + } finally { + await db.update(permissions).set({ permissionType: 'admin' }).where(ownPermissions) + } +}) +await check('Wrong file owner is concealed and unauthenticated calls are refused', async () => { + assert.equal( + (await request(`/api/projects/${projectId}/files/${workspaceFileId}/share`)).status, + 404 + ) + assert.equal((await request(path, 'GET', undefined, false)).status, 401) +}) +await check('Project publication serves actual bytes with stable token', async () => { + const share = toRecord( + json(await request(path, 'PUT', { isActive: true, authType: 'public' })).share + ) + token = required(share.token) + assert.equal(share.resourceId, fileId) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).text, + content + ) + assert.equal(toRecord(json(await request(path)).share).token, token) +}) +await check( + 'Another environment policy controls offered modes and actual publication', + async () => { + await db + .update(permissionGroup) + .set({ config: { allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + assert.deepEqual(json(await request(path)).policy, { + canPublish: true, + allowedAuthTypes: ['password'], + }) + assert.equal((await request(path, 'PUT', { isActive: true, authType: 'public' })).status, 403) + const share = toRecord( + json( + await request(path, 'PUT', { + isActive: true, + authType: 'password', + password: 'Local-sharing-proof-password', + }) + ).share + ) + assert.equal(share.authType, 'password') + assert.equal(share.token, token) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).status, + 401 + ) + } +) +await check('Disabled publication preserves the right to revoke current sharing', async () => { + await db + .update(permissionGroup) + .set({ config: { disablePublicFileSharing: true, allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + assert.deepEqual(json(await request(path)).policy, { + canPublish: false, + allowedAuthTypes: ['password'], + }) + assert.equal((await request(path, 'PUT', { isActive: true, authType: 'password' })).status, 403) + const share = toRecord(json(await request(path, 'PUT', { isActive: false })).share) + assert.equal(share.isActive, false) + assert.equal(share.token, token) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).status, + 404 + ) +}) +await check('Environment sharing keeps its separate legacy policy and identity', async () => { + const workspacePath = `/api/workspaces/${workspaceId}/files/${workspaceFileId}/share` + const shared = json(await request(workspacePath, 'PUT', { isActive: true, authType: 'public' })) + assert.deepEqual(Object.keys(shared), ['share']) + const share = toRecord(shared.share) + assert.equal(share.resourceId, workspaceFileId) + assert.notEqual(share.token, token) + assert.equal(toRecord(json(await request(path)).share).isActive, false) + assert.equal( + (await request(`/api/files/public/${required(share.token)}/content`, 'GET', undefined, false)) + .text, + 'Environment only fixture' + ) + json(await request(workspacePath, 'PUT', { isActive: false })) +}) +await request(`/api/workspaces/${workspaceId}/files/${workspaceFileId}/share`, 'PUT', { + isActive: false, +}) +await request(path, 'PUT', { isActive: false }) +await writeFile( + join(fixtureDir, 'sharing-browser-fixtures.json'), + JSON.stringify( + { + projectId, + workspaceId, + otherWorkspaceId, + organizationId, + groupId, + actorId: actor.id, + fileId, + workspaceFileId, + token, + }, + null, + 2 + ), + { mode: 0o600 } +) +process.stdout.write( + `${JSON.stringify({ + reportPath, + passed: checks.filter((c) => c.passed).length, + failed: checks.filter((c) => !c.passed).length, + })}\n` +) +process.exit(checks.every((c) => c.passed) ? 0 : 1) diff --git a/apps/sim/scripts/test-project-files-e2e.ts b/apps/sim/scripts/test-project-files-e2e.ts index 423806ae1f9..20562b595b2 100644 --- a/apps/sim/scripts/test-project-files-e2e.ts +++ b/apps/sim/scripts/test-project-files-e2e.ts @@ -291,6 +291,66 @@ async function seed(name: string) { return { directory, projectId, workspaceId, foreignProjectId, cookie } } +async function seedInline(fixture: Awaited>) { + const image = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jF9sAAAAASUVORK5CYII=' + const create = async ( + projectId: string, + name: string, + content: string, + contentType: string, + encoding: string + ) => + toRecord( + ( + await request(fixture.cookie, `/api/projects/${projectId}/files`, { + name, + content, + contentType, + encoding, + }) + ).file + ) + const ownImage = await create(fixture.projectId, 'own.png', image, 'image/png', 'base64') + const foreignImage = await create( + fixture.foreignProjectId, + 'foreign.png', + image, + 'image/png', + 'base64' + ) + const foreignPath = `/api/projects/${fixture.foreignProjectId}/files/${foreignImage.id}/content` + const doc = await create( + fixture.projectId, + 'architecture.md', + `![own](sim:file/${ownImage.id}?project=${fixture.projectId})\n![foreign](${foreignPath})`, + 'text/markdown', + 'utf-8' + ) + const share = toRecord( + ( + await request( + fixture.cookie, + `/api/projects/${fixture.projectId}/files/${doc.id}/share`, + { isActive: true, authType: 'public' }, + 'PUT' + ) + ).share + ) + secrets.add(requiredString(share.token)) + const path = join(fixture.directory, 'inline-source-fixture.json') + await writeFile( + path, + JSON.stringify({ + projectId: fixture.projectId, + files: [{ owner: 'project', fileId: doc.id, imageId: ownImage.id, token: share.token }], + wrongOwner: { projectId: fixture.foreignProjectId, fileId: foreignImage.id }, + }), + { mode: 0o600 } + ) + return path +} + function interrupt() { interrupted = true void Promise.all(processes.map(stop)).catch(() => undefined) @@ -303,6 +363,9 @@ const suites = [ ['browser', 'PROJECT_FILE_BROWSER', 'test-project-file-browser-e2e.ts'], ['history', 'PROJECT_FILE_HISTORY', 'test-project-file-history-e2e.ts'], ['rendered', 'PROJECT_FILE_RENDERED', 'test-project-file-rendered-e2e.ts'], + ['public', 'PROJECT_FILE_PUBLIC', 'test-project-file-public-e2e.ts'], + ['sharing', 'PROJECT_FILE_SHARING', 'test-project-file-sharing-e2e.ts'], + ['inline-source', 'PROJECT_INLINE_SOURCE', 'test-project-file-inline-source-e2e.ts'], ['copy', 'FILE_COPY', 'test-file-copy-e2e.ts'], ['realtime', 'FILE_LIST_REALTIME', 'test-file-list-realtime-e2e.ts'], ] as const @@ -357,6 +420,7 @@ try { assert.equal(interrupted, false, 'Acceptance run interrupted') await check(`Existing ${name} acceptance suite`, async () => { const fixture = await seed(name) + const fixturePath = name === 'inline-source' ? await seedInline(fixture) : undefined await run( `project-files-${name}`, ['bun', '--no-env-file', join('scripts', script)], @@ -368,6 +432,7 @@ try { [`${prefix}_REPORT_PATH`]: join(reportDir, `project-files-${name}.json`), PROJECT_FILE_RENDERED_FOREIGN_PROJECT_ID: fixture.foreignProjectId, PROJECT_FILE_PUBLIC_WORKSPACE_ID: fixture.workspaceId, + PROJECT_INLINE_SOURCE_FIXTURE_PATH: fixturePath, FILE_COPY_WORKSPACE_ID: fixture.workspaceId, FILE_LIST_REALTIME_RELAY_URL: relay.origin, } diff --git a/knip.jsonc b/knip.jsonc index 4d94aaa19d9..640b77d6370 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -59,6 +59,8 @@ "lib/api/contracts/file-copy-input.ts": ["exports", "types"], // API contract conventions require named boundary schemas and wire aliases before client adoption. "lib/api/contracts/project-file-downloads.ts": ["exports", "types"], + "lib/api/contracts/project-file-shares.ts": ["exports", "types"], + "lib/api/contracts/v2/project-file-shares.ts": ["exports", "types"], "lib/api/contracts/project-file-extraction.ts": ["exports", "types"], "lib/api/contracts/v2/project-file-downloads.ts": ["exports", "types"], "lib/api/contracts/v2/project-file-extraction.ts": ["exports", "types"], diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 2498dcd88ce..2fa3aa993cb 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1799,6 +1799,11 @@ export const CLI_CONTRACT: CliContract = { describe: 'Show a file’s share settings', fields: FILE_SHARE_FIELDS, }, + getProjectFileShare: { + command: 'projects files share get', + describe: 'Show a Project file’s share settings', + fields: FILE_SHARE_FIELDS, + }, // v2 folds share and unshare into one PATCH; `--is-active false` disables it, // so there is no separate unshare operation to expose. upsertFileShare: { @@ -1809,6 +1814,14 @@ export const CLI_CONTRACT: CliContract = { }, fields: FILE_SHARE_FIELDS, }, + updateProjectFileShare: { + command: 'projects files share set', + describe: 'Enable or disable sharing for a Project file', + flags: { + allowedEmails: { list: true }, + }, + fields: FILE_SHARE_FIELDS, + }, /** * None of the four folder lists paginates: the route declares no `cursor` and diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index f9bdda640cb..61c7a43f0c0 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -6685,6 +6685,30 @@ export type GetProjectFileMetadataResponse = { data: GetProjectFileMetadataResponseRef0 } +/** `GET /api/v2/projects/[projectId]/files/[fileId]/share` */ +export type GetProjectFileShareParams = { + projectId: string + fileId: string +} + +export type GetProjectFileShareQuery = Record + +type GetProjectFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type GetProjectFileShareResponse = { + data: GetProjectFileShareResponseRef0 | null +} + /** `GET /api/v2/projects/[projectId]/files/uploads/[uploadId]` */ export type GetProjectFileUploadParams = { projectId: string @@ -14678,6 +14702,37 @@ export type UpdateProjectFileFolderResponse = { } } +/** `PATCH /api/v2/projects/[projectId]/files/[fileId]/share` */ +export type UpdateProjectFileShareParams = { + projectId: string + fileId: string +} + +export type UpdateProjectFileShareQuery = Record + +export type UpdateProjectFileShareBody = { + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +type UpdateProjectFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type UpdateProjectFileShareResponse = { + data: UpdateProjectFileShareResponseRef0 +} + /** `PATCH /api/v2/tables/[tableId]/rows` */ export type UpdateRowsByFilterParams = { tableId: string @@ -18716,6 +18771,18 @@ export const V2_OPERATIONS = { summary: 'Get Project File Metadata', workspaceKeyUnsupported: true, }, + getProjectFileShare: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project File Share', + workspaceKeyUnsupported: true, + }, getProjectFileUpload: { method: 'GET', path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', @@ -24113,6 +24180,42 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'integer', describe: 'New manual position; omit to leave unchanged.' }, }, }, + updateProjectFileShare: { + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Update Project File Share', + workspaceKeyUnsupported: true, + body: { + isActive: { + kind: 'boolean', + required: true, + describe: + 'Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use.', + }, + authType: { + kind: 'enum', + values: ['public', 'password', 'email', 'sso'] as const, + describe: + 'How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.', + }, + password: { + kind: 'string', + describe: + 'Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected.', + }, + allowedEmails: { + kind: 'array', + describe: + 'Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.', + }, + }, + }, updateRowsByFilter: { method: 'PATCH', path: '/api/v2/tables/[tableId]/rows', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index ef6beb2d219..63146764eb6 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -433,6 +433,7 @@ describe('destructive operations are gated', () => { 'unzipProjectFile', 'updateProjectFileContent', 'updateProjectFileFolder', + 'updateProjectFileShare', /** These amend access or request history without discarding a resource. */ 'cancelOrganizationAccessRequest', 'cancelWorkspaceAccessRequest', diff --git a/packages/testing/src/mocks/public-shares.mock.ts b/packages/testing/src/mocks/public-shares.mock.ts index 471efb888b5..98a3980e1f7 100644 --- a/packages/testing/src/mocks/public-shares.mock.ts +++ b/packages/testing/src/mocks/public-shares.mock.ts @@ -26,7 +26,7 @@ export class MockShareValidationError extends Error { * ```ts * import { publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' * - * publicSharesMockFns.mockResolveActiveShareByToken.mockResolvedValue(share) + * publicSharesMockFns.mockGetShareForResource.mockResolvedValue(share) * ``` */ export const publicSharesMockFns = { @@ -42,7 +42,6 @@ export const publicSharesMockFns = { async (..._args: unknown[]): Promise> => new Map() ), mockUpsertFileShare: vi.fn(), - mockResolveActiveShareByToken: vi.fn(), } /** @@ -62,5 +61,4 @@ export const publicSharesMock = { getWorkspaceSharesForResources: publicSharesMockFns.mockGetWorkspaceSharesForResources, getWorkspaceShares: publicSharesMockFns.mockGetWorkspaceShares, upsertFileShare: publicSharesMockFns.mockUpsertFileShare, - resolveActiveShareByToken: publicSharesMockFns.mockResolveActiveShareByToken, } diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 5fc9df0e02c..032ed628d74 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1084,7 +1084,6 @@ "apps/sim/hooks/queries/providers.ts#providerKeys", "apps/sim/hooks/queries/providers.ts#providerModelsQueryOptions", "apps/sim/hooks/queries/public-shares.ts#FILE_SHARE_STALE_TIME", - "apps/sim/hooks/queries/public-shares.ts#shareKeys", "apps/sim/hooks/queries/resume-execution.ts#PausedExecutionSummary", "apps/sim/hooks/queries/resume-execution.ts#RESUME_EXECUTION_DETAIL_STALE_TIME", "apps/sim/hooks/queries/resume-execution.ts#ResumeLinks", @@ -1820,7 +1819,6 @@ "apps/sim/lib/api/contracts/primitives.ts#retentionOverrideSchema", "apps/sim/lib/api/contracts/providers.ts#openRouterModelInfoSchema", "apps/sim/lib/api/contracts/public-shares.ts#AuthenticatePublicFileBody", - "apps/sim/lib/api/contracts/public-shares.ts#GetFileShareResponse", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileMetadata", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileSSOBody", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileSSOResponse", @@ -3254,13 +3252,11 @@ "apps/sim/lib/api/contracts/v2/files.ts#v2FileMetadataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileReferenceParamsSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileScopeSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileShareSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadParamsSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadWorkspaceQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2GetFileMetadataQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2ListFileFoldersQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2MoveFileItemsResultSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2NullableFileShareSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderDataSchema",