diff --git a/.env.example b/.env.example index 5726655396..d9a32d076b 100644 --- a/.env.example +++ b/.env.example @@ -828,6 +828,10 @@ AZURE_CONTAINER_NAME=files ALLOW_SHARED_LINKS=true # Allows unauthenticated access to shared links. Defaults to false (auth required) if not set. ALLOW_SHARED_LINKS_PUBLIC=false +# Snapshot files referenced by a shared chat so viewers can preview/download them through +# the shared link (instead of the owner's file ACL). Enabled by default; overrides the +# `interface.sharedLinks.snapshotFiles` yaml setting when set. +# SHARED_LINKS_SNAPSHOT_FILES=true #==============================# # Static File Cache Control # diff --git a/api/server/middleware/accessResources/fileAccess.js b/api/server/middleware/accessResources/fileAccess.js index e1c5803c64..0ce391371e 100644 --- a/api/server/middleware/accessResources/fileAccess.js +++ b/api/server/middleware/accessResources/fileAccess.js @@ -108,7 +108,9 @@ const fileAccess = async (req, res, next) => { // Tenant-scoped files are restricted to their tenant. Legacy files without // tenantId remain governed by owner/agent ACLs for non-tenant migrations. if (fileTenantId && fileTenantId !== userTenantId) { - logger.warn(`[fileAccess] User ${userId} denied cross-tenant access to file ${fileId}`); + logger.warn( + `[fileAccess] User ${userId} denied cross-tenant access to file ${fileId} (route ${req.originalUrl})`, + ); return denyFileAccess(res); } @@ -129,7 +131,9 @@ const fileAccess = async (req, res, next) => { return next(); } - logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`); + logger.warn( + `[fileAccess] User ${userId} denied access to file ${fileId} (route ${req.originalUrl})`, + ); return denyFileAccess(res); } catch (error) { logger.error('[fileAccess] Error checking file access:', error); diff --git a/api/server/middleware/optionalShareFileAuth.js b/api/server/middleware/optionalShareFileAuth.js new file mode 100644 index 0000000000..f5c4ac07a5 --- /dev/null +++ b/api/server/middleware/optionalShareFileAuth.js @@ -0,0 +1,70 @@ +const cookie = require('cookie'); +const jwt = require('jsonwebtoken'); +const { isEnabled } = require('@librechat/api'); +const { logger, runAsSystem } = require('@librechat/data-schemas'); +const { SystemRoles } = require('librechat-data-provider'); +const { getUserById } = require('~/models'); + +const verifyRefreshToken = (token) => { + try { + const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET); + return typeof payload?.id === 'string' ? payload.id : null; + } catch { + return null; + } +}; + +/** + * Fallback auth for share file routes that are hit by ``/anchor requests, + * which can't carry the bearer access token. Resolves the viewer from the + * `refreshToken` cookie (or the signed `openid_user_id` cookie) — the same + * mechanism secure image links use — so non-public shared links can authorize + * the viewer's ACL. Never blocks: on any failure it leaves `req.user` unset and + * lets `canAccessSharedLink` decide (public access, 401, or 403). + */ +const optionalShareFileAuth = async (req, res, next) => { + if (req.user) { + return next(); + } + + try { + const cookieHeader = req.headers.cookie; + if (!cookieHeader) { + return next(); + } + + const parsed = cookie.parse(cookieHeader); + const useOpenId = + parsed.token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS); + const token = useOpenId ? parsed.openid_user_id : parsed.refreshToken; + if (!token) { + return next(); + } + + const userId = verifyRefreshToken(token); + if (!userId) { + return next(); + } + + // Resolve in system context: this runs before canAccessSharedLink establishes + // the share tenant, so under strict tenant isolation a tenant-scoped User + // query would otherwise throw. The viewer's id comes from their own verified + // refresh token; the share's tenant-scoped ACL check still gates access. + const user = await runAsSystem(() => + getUserById(userId, '-password -__v -totpSecret -backupCodes'), + ); + if (user) { + user.id = user._id.toString(); + if (!user.role) { + user.role = SystemRoles.USER; + } + req.user = user; + } + } catch (error) { + logger.warn('[optionalShareFileAuth] cookie auth failed:', error?.message); + } + + return next(); +}; + +module.exports = optionalShareFileAuth; diff --git a/api/server/middleware/optionalShareFileAuth.spec.js b/api/server/middleware/optionalShareFileAuth.spec.js new file mode 100644 index 0000000000..96e147198b --- /dev/null +++ b/api/server/middleware/optionalShareFileAuth.spec.js @@ -0,0 +1,84 @@ +const mockVerify = jest.fn(); +const mockGetUserById = jest.fn(); + +jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) })); +jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true })); +jest.mock('@librechat/data-schemas', () => ({ + logger: { warn: jest.fn(), error: jest.fn() }, + runAsSystem: (fn) => fn(), +})); +jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } })); +jest.mock('~/models', () => ({ getUserById: (...args) => mockGetUserById(...args) })); + +const optionalShareFileAuth = require('./optionalShareFileAuth'); + +const run = async (req) => { + const next = jest.fn(); + await optionalShareFileAuth(req, {}, next); + return next; +}; + +describe('optionalShareFileAuth', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.JWT_REFRESH_SECRET = 'test-secret'; + }); + + it('short-circuits when a bearer user is already set (no cookie work)', async () => { + const req = { user: { id: 'u1' }, headers: { cookie: 'refreshToken=x' } }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(mockVerify).not.toHaveBeenCalled(); + expect(mockGetUserById).not.toHaveBeenCalled(); + }); + + it('resolves the viewer from a valid refreshToken cookie', async () => { + mockVerify.mockReturnValue({ id: 'viewer-1' }); + mockGetUserById.mockResolvedValue({ _id: 'viewer-1', role: 'USER' }); + const req = { headers: { cookie: 'refreshToken=good.jwt' } }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(mockVerify).toHaveBeenCalledWith('good.jwt', 'test-secret'); + expect(req.user).toMatchObject({ id: 'viewer-1', role: 'USER' }); + }); + + it('defaults the role to USER when the record has none', async () => { + mockVerify.mockReturnValue({ id: 'viewer-2' }); + mockGetUserById.mockResolvedValue({ _id: 'viewer-2' }); + const req = { headers: { cookie: 'refreshToken=good.jwt' } }; + await run(req); + expect(req.user.role).toBe('USER'); + }); + + it('leaves req.user unset when there is no cookie', async () => { + const req = { headers: {} }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toBeUndefined(); + expect(mockGetUserById).not.toHaveBeenCalled(); + }); + + it('leaves req.user unset when the token is invalid', async () => { + mockVerify.mockImplementation(() => { + throw new Error('bad token'); + }); + const req = { headers: { cookie: 'refreshToken=bad' } }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toBeUndefined(); + expect(mockGetUserById).not.toHaveBeenCalled(); + }); + + it('uses the signed openid_user_id cookie for OpenID-reuse sessions', async () => { + process.env.OPENID_REUSE_TOKENS = 'true'; + mockVerify.mockReturnValue({ id: 'oidc-1' }); + mockGetUserById.mockResolvedValue({ _id: 'oidc-1', role: 'USER' }); + const req = { + headers: { cookie: 'token_provider=openid; openid_user_id=signed.jwt' }, + }; + await run(req); + expect(mockVerify).toHaveBeenCalledWith('signed.jwt', 'test-secret'); + expect(req.user).toMatchObject({ id: 'oidc-1' }); + delete process.env.OPENID_REUSE_TOKENS; + }); +}); diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index e26dd9be6c..ecbd820ae6 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -14,14 +14,18 @@ jest.mock('@librechat/api', () => ({ updateSharedLinkPermissionsExpiration: (...args) => mockUpdateSharedLinkPermissionsExpiration(...args), ensureLinkPermissions: jest.fn(), + isFileSnapshotEnabled: jest.fn(() => true), + isFileSnapshotKillSwitchActive: jest.fn(() => false), deleteSharedLinkWithCleanup: jest.fn(), getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args), isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()), })); jest.mock('@librechat/data-schemas', () => ({ - logger: { error: jest.fn() }, + logger: { error: jest.fn(), warn: jest.fn() }, createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')), + runAsSystem: jest.fn((fn) => fn()), + tenantStorage: { run: jest.fn((_ctx, fn) => fn()) }, })); jest.mock('librechat-data-provider', () => ({ @@ -36,6 +40,14 @@ jest.mock('librechat-data-provider', () => ({ ALL: 'all', TEMPORARY: 'temporary', }, + FileSources: { + local: 'local', + s3: 's3', + cloudfront: 'cloudfront', + azure_blob: 'azure_blob', + firebase: 'firebase', + text: 'text', + }, })); jest.mock('mongoose', () => ({ @@ -50,26 +62,50 @@ jest.mock('mongoose', () => ({ })); jest.mock('~/models', () => ({ + getFiles: jest.fn(), + updateFile: jest.fn(), getSharedMessages: jest.fn(), createSharedLink: jest.fn(), updateSharedLink: jest.fn(), deleteSharedLink: jest.fn(), getSharedLinks: jest.fn(), getSharedLink: jest.fn(), + getSharedLinkFile: jest.fn(), + backfillSharedLinkFiles: jest.fn(), getRoleByName: jest.fn(), })); +const mockGetStrategyFunctions = jest.fn(); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), +})); +jest.mock('~/server/utils/files', () => ({ + cleanFileName: jest.fn((name) => name), + getContentDisposition: jest.fn((name, disposition = 'attachment') => `${disposition}; ${name}`), +})); + jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next()); +jest.mock('~/server/middleware/optionalShareFileAuth', () => (_req, _res, next) => next()); jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next()); jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()); +jest.mock('~/server/middleware/config/app', () => (_req, _res, next) => next()); +const { Readable } = require('stream'); const { RetentionMode } = require('librechat-data-provider'); const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas'); -const { deleteSharedLinkWithCleanup } = require('@librechat/api'); const { + deleteSharedLinkWithCleanup, + isFileSnapshotEnabled, + isFileSnapshotKillSwitchActive, +} = require('@librechat/api'); +const { + getFiles, + updateFile, getSharedMessages, createSharedLink, updateSharedLink, + getSharedLinkFile, + backfillSharedLinkFiles, getRoleByName, } = require('~/models'); const shareRouter = require('../share'); @@ -147,6 +183,7 @@ describe('share routes retention', () => { 'convo-123', 'msg-123', new Date('2030-01-01T00:00:00.000Z'), + true, ); expect(mockGrantCreationPermissions).toHaveBeenCalledWith( 'link-123', @@ -157,6 +194,72 @@ describe('share routes retention', () => { expect(mockSharedLinksAccess).toHaveBeenCalled(); }); + it('snapshots files by default when the user does not opt out', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + await request(buildApp()).post('/api/share/convo-123').send({ targetMessageId: 'msg-123' }); + + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + expect.anything(), + true, + ); + }); + + it('does not snapshot files when the user opts out (snapshotFiles=false)', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + await request(buildApp()) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123', snapshotFiles: false }); + + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + expect.anything(), + false, + ); + }); + + it('forces snapshotFiles=false when the feature is disabled, ignoring the body flag', async () => { + isFileSnapshotEnabled.mockReturnValueOnce(false); + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + await request(buildApp()) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123', snapshotFiles: true }); + + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + expect.anything(), + false, + ); + }); + + it('passes the snapshotFiles opt-out through on update', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); + + await request(buildApp()).patch('/api/share/share-123').send({ snapshotFiles: false }); + + expect(updateSharedLink).toHaveBeenCalledWith( + 'user-123', + 'share-123', + undefined, + expect.anything(), + false, + ); + }); + it('rejects new shares when the retained conversation expired', async () => { mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); @@ -210,6 +313,7 @@ describe('share routes retention', () => { 'share-123', undefined, new Date('2030-01-01T00:00:00.000Z'), + true, ); expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith( 'link-456', @@ -253,7 +357,7 @@ describe('share routes retention', () => { const response = await request(buildApp()).patch('/api/share/share-123'); expect(response.status).toBe(200); - expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null); + expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true); expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null); expect(mockSharedLinksAccess).not.toHaveBeenCalled(); }); @@ -266,7 +370,13 @@ describe('share routes retention', () => { const response = await request(buildApp()).patch('/api/share/share-123'); expect(response.status).toBe(200); - expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, undefined); + expect(updateSharedLink).toHaveBeenCalledWith( + 'user-123', + 'share-123', + undefined, + undefined, + true, + ); expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled(); }); @@ -286,7 +396,7 @@ describe('share routes retention', () => { '[getSharedLinkExpiration] Error creating expiration date:', error, ); - expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null); + expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true); expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null); }); @@ -305,6 +415,7 @@ describe('share routes retention', () => { 'share-123', 'msg-456', new Date('2030-01-01T00:00:00.000Z'), + true, ); }); @@ -327,3 +438,233 @@ describe('share routes retention', () => { expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123'); }); }); + +describe('share-scoped file routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetStrategyFunctions.mockReturnValue({ + getDownloadStream: jest.fn(async () => Readable.from(['file-bytes'])), + }); + // Live file record present by default (resolveShareFile requires it). + getFiles.mockResolvedValue([{ status: 'ready' }]); + }); + + it('serves a snapshotted image inline from its original stored object', async () => { + const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes'])); + mockGetStrategyFunctions.mockReturnValue({ getDownloadStream }); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/images/owner/pic.png', + type: 'image/png', + filename: 'pic.png', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('image/png'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + expect(response.headers['content-disposition']).toContain('inline'); + expect(mockGetStrategyFunctions).toHaveBeenCalledWith('local'); + expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png'); + expect(backfillSharedLinkFiles).not.toHaveBeenCalled(); + }); + + it('forces attachment for unsafe inline types (no stored XSS)', async () => { + const getDownloadStream = jest.fn(async () => Readable.from([''])); + mockGetStrategyFunctions.mockReturnValue({ getDownloadStream }); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/uploads/owner/evil.svg', + type: 'image/svg+xml', + filename: 'evil.svg', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/octet-stream'); + expect(response.headers['content-disposition']).toContain('attachment'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('downloads a snapshotted file as an attachment', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/uploads/owner/file-1', + type: 'application/pdf', + filename: 'report.pdf', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/download'); + + expect(response.status).toBe(200); + expect(response.headers['content-disposition']).toContain('attachment'); + }); + + it('returns preview status read live from the file record', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local' }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', text: 'extracted text', textFormat: 'text' }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + file_id: 'file-1', + status: 'ready', + text: 'extracted text', + textFormat: 'text', + }); + expect(getFiles).toHaveBeenCalledWith({ file_id: 'file-1' }, null, {}); + }); + + it('404s for a file not in the snapshot without rebuilding it', async () => { + getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: true }); + + const response = await request(buildApp()).get('/api/share/share-123/files/not-shared'); + + expect(response.status).toBe(404); + expect(backfillSharedLinkFiles).not.toHaveBeenCalled(); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('lazily backfills only a legacy share that has no snapshot field', async () => { + getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false }); + backfillSharedLinkFiles.mockResolvedValue({ + file_id: 'file-1', + source: 'local', + filepath: '/images/owner/pic.png', + type: 'image/png', + filename: 'pic.png', + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(backfillSharedLinkFiles).toHaveBeenCalledWith('share-123', 'file-1'); + }); + + it('404s cleanly when the snapshotted file is no longer available', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/gone.pdf' }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([]); // original record deleted/expired + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s (no serving) when the global kill switch is active', async () => { + isFileSnapshotKillSwitchActive.mockReturnValueOnce(true); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(getSharedLinkFile).not.toHaveBeenCalled(); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s (no serving, no backfill) for a link that opted out of file sharing', async () => { + getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false, optedOut: true }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(backfillSharedLinkFiles).not.toHaveBeenCalled(); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s when the snapshotted file version was overwritten (revision mismatch)', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/uploads/owner/x', + previewRevision: 'r1', + }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', previewRevision: 'r2' }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s when the snapshotted file was overwritten (size/bytes mismatch)', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/x', bytes: 100 }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', bytes: 200 }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('strips a cache-busting query string before local streaming', async () => { + const getDownloadStream = jest.fn(async () => Readable.from(['bytes'])); + mockGetStrategyFunctions.mockReturnValue({ getDownloadStream }); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/images/owner/pic.png?v=2', + type: 'image/png', + filename: 'pic.png', + bytes: 100, + }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', bytes: 100 }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png'); + }); + + it('sweeps an orphaned pending preview to failed', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local' }, + hasSnapshots: true, + }); + const stale = new Date(Date.now() - 5 * 60 * 1000); + getFiles.mockResolvedValue([{ status: 'pending', updatedAt: stale }]); + updateFile.mockResolvedValue({ status: 'failed', previewError: 'orphaned' }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + file_id: 'file-1', + status: 'failed', + previewError: 'orphaned', + }); + expect(updateFile).toHaveBeenCalledWith( + { file_id: 'file-1', status: 'failed', previewError: 'orphaned' }, + { status: 'pending', updatedAt: stale }, + ); + }); +}); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 79f36552ca..19f25b844f 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -6,6 +6,7 @@ const { resolveBuildInfo, resolveTitleTiming, sanitizeModelSpecs, + isFileSnapshotEnabled, } = require('@librechat/api'); const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); @@ -257,6 +258,7 @@ router.get('/', async function (req, res) { ...preLoginPayload, ...publicSharePayload, ...buildPostLoginPayload(), + sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, titleGenerationTiming: resolveTitleTiming({ diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 03cc00fbc2..09f84be357 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -5,24 +5,39 @@ const { generateCheckAccess, grantCreationPermissions, ensureLinkPermissions, + isFileSnapshotEnabled, + isFileSnapshotKillSwitchActive, deleteSharedLinkWithCleanup, updateSharedLinkPermissionsExpiration, isActiveExpirationDate, getSharedLinkExpiration, } = require('@librechat/api'); -const { logger, createTempChatExpirationDate } = require('@librechat/data-schemas'); -const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { + logger, + runAsSystem, + tenantStorage, + createTempChatExpirationDate, +} = require('@librechat/data-schemas'); +const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider'); +const { + getFiles, + updateFile, getSharedMessages, createSharedLink, updateSharedLink, getSharedLinks, getSharedLink, + getSharedLinkFile, + backfillSharedLinkFiles, getRoleByName, } = require('~/models'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { cleanFileName, getContentDisposition } = require('~/server/utils/files'); const canAccessSharedLink = require('~/server/middleware/canAccessSharedLink'); +const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth'); const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const configMiddleware = require('~/server/middleware/config/app'); const router = express.Router(); const checkSharedLinksAccess = generateCheckAccess({ @@ -53,21 +68,265 @@ const resolveSharedLinkExpiration = (req, conversationId) => const allowSharedLinks = process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS); -if (allowSharedLinks) { - router.get('/:shareId', optionalJwtAuth, canAccessSharedLink, async (req, res) => { +/** Run within the snapshot file's tenant context (mirrors canAccessSharedLink). */ +const runWithTenant = (tenantId, fn) => + tenantId ? tenantStorage.run({ tenantId }, fn) : runAsSystem(fn); + +/** Mirrors the owner preview route: pending records older than this are swept to + * 'failed' on the next poll so the client poller terminates. */ +const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000; + +/** + * MIME types that are safe to render inline. Everything else (text/html, SVG, + * and other active content) is served as an `attachment` so a public viewer + * can't execute uploaded bytes under the app origin by opening the URL directly. + */ +const SAFE_INLINE_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/avif', + 'image/x-icon', + 'application/pdf', +]); + +/** + * Resolve a snapshotted file for a shared link. A file_id absent from the + * share's snapshot is denied (404) — this prevents a viewer from reaching files + * outside the shared-link snapshot. Only legacy shares (no `fileSnapshots` field + * at all) trigger a lazy backfill; an ordinary miss does not rebuild. The live + * file record is also required: if the original was deleted/expired, return a + * clean 404 instead of letting the stream error after headers are sent. + */ +const resolveShareFile = async (req, res, next) => { + try { + // Global kill switch only (env-based, viewer-independent): disabling stops + // serving for every link. The viewer's own config must NOT affect serving. + if (isFileSnapshotKillSwitchActive()) { + return res.status(404).json({ message: 'Shared file access is disabled' }); + } + + const { shareId, file_id } = req.params; + const { file, hasSnapshots, optedOut } = await getSharedLinkFile(shareId, file_id); + // Per-link opt-out: never serve and never backfill an opted-out link. + if (optedOut) { + return res.status(404).json({ message: 'File not found in shared link' }); + } + let snapshot = file; + if (!snapshot && !hasSnapshots) { + snapshot = await backfillSharedLinkFiles(shareId, file_id); + } + if (!snapshot) { + logger.warn( + `[shareFileAccess] File ${file_id} not in snapshot for share ${shareId} (route ${req.originalUrl})`, + ); + return res.status(404).json({ message: 'File not found in shared link' }); + } + + const [liveFile] = await getFiles({ file_id }, null, {}); + if (!liveFile) { + logger.warn( + `[shareFileAccess] Snapshotted file ${file_id} no longer available for share ${shareId}`, + ); + return res.status(404).json({ message: 'File no longer available' }); + } + + // Pin to the snapshotted version so an old link can't surface post-share content + // after a reused file_id (e.g. code-exec same-filename outputs) is overwritten. + // previewRevision changes for deferred/office files; `bytes` catches other + // overwrites that change size, and is stable across S3 URL refresh and the + // pending->ready transition (which don't alter file size). Same-size content + // swaps remain a best-effort gap inherent to the no-byte-copy design. + const revisionChanged = + (snapshot.previewRevision ?? null) !== (liveFile.previewRevision ?? null); + const bytesChanged = + snapshot.bytes != null && liveFile.bytes != null && snapshot.bytes !== liveFile.bytes; + if (revisionChanged || bytesChanged) { + logger.warn( + `[shareFileAccess] Snapshot version mismatch for file ${file_id} (share ${shareId})`, + ); + return res.status(404).json({ message: 'File no longer available' }); + } + + req.shareFile = snapshot; + req.liveFile = liveFile; + return next(); + } catch (error) { + logger.error('[shareFileAccess] Error resolving shared file:', error); + return res.status(500).json({ message: 'Error resolving shared file' }); + } +}; + +/** Stream (or redirect to) a snapshotted file from its original stored object. */ +const streamSharedFile = async (req, res, file, requestedDisposition) => { + const source = file.source || FileSources.local; + const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source); + + // Inline only safe preview types; anything else is forced to attachment. + const disposition = + requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment'; + + // Redirect to a signed storage URL only when explicitly requested (?direct=true); + // by default stream through the server so blob (XHR) callers work without bucket CORS. + const isDirectSource = source === FileSources.s3 || source === FileSources.cloudfront; + if (req.query.direct === 'true' && getDownloadURL && isDirectSource) { try { - const share = await getSharedMessages(req.params.shareId, req.shareResourceId); - if (share) { - res.set('Cache-Control', 'private, no-store'); - res.status(200).json(share); - } else { - res.status(404).end(); + const url = await getDownloadURL({ + req, + file, + customFilename: cleanFileName(file.filename), + contentType: file.type || 'application/octet-stream', + }); + if (url) { + res.setHeader('Cache-Control', 'no-store'); + return res.redirect(302, url); } } catch (error) { - logger.error('Error getting shared messages:', error); - res.status(500).json({ message: 'Error getting shared messages' }); + logger.warn('[shareFileAccess] download URL generation failed, streaming instead:', error); } + } + + if (!getDownloadStream) { + return res.status(501).send('Not Implemented'); + } + + // Strip any cache-busting query string (e.g. code-output images add `?v=...`) so + // the local stream resolves the real filename, not a literal `*.png?v=...` path. + const streamPath = (file.storageKey || file.filepath || '').split('?')[0]; + const fileStream = await getDownloadStream(req, streamPath); + fileStream.on('error', (error) => { + logger.error('[shareFileAccess] Stream error:', error); }); + + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Disposition', getContentDisposition(file.filename, disposition)); + res.setHeader( + 'Content-Type', + disposition === 'inline' ? file.type || 'application/octet-stream' : 'application/octet-stream', + ); + res.setHeader('Cache-Control', 'private, max-age=3600'); + return fileStream.pipe(res); +}; + +if (allowSharedLinks) { + router.get( + '/:shareId', + optionalJwtAuth, + canAccessSharedLink, + configMiddleware, + async (req, res) => { + try { + const share = await getSharedMessages(req.params.shareId, req.shareResourceId, { + // Viewer-independent: the per-link choice (stored on the share) decides + // file inclusion; only a global env kill switch can force it off here. + snapshotFiles: !isFileSnapshotKillSwitchActive(), + }); + if (share) { + res.set('Cache-Control', 'private, no-store'); + res.status(200).json(share); + } else { + res.status(404).end(); + } + } catch (error) { + logger.error('Error getting shared messages:', error); + res.status(500).json({ message: 'Error getting shared messages' }); + } + }, + ); + + /** + * Preview status for a snapshotted file. Read live from the file record so the + * status is always current (deferred previews may resolve after the share was + * created) and large extracted text is never embedded in the share document. + */ + router.get( + '/:shareId/files/:file_id/preview', + optionalJwtAuth, + optionalShareFileAuth, + canAccessSharedLink, + configMiddleware, + resolveShareFile, + async (req, res) => { + try { + const { file_id } = req.params; + let liveFile = req.liveFile; + // Lazy-sweep orphaned pending records to 'failed' so the client preview + // poller reaches a terminal state (mirrors the owner preview route). + if (liveFile?.status === 'pending' && liveFile.updatedAt instanceof Date) { + const ageMs = Date.now() - liveFile.updatedAt.getTime(); + if (ageMs > PREVIEW_LAZY_SWEEP_CUTOFF_MS) { + const swept = await updateFile( + { file_id, status: 'failed', previewError: 'orphaned' }, + { status: 'pending', updatedAt: liveFile.updatedAt }, + ); + if (swept) { + liveFile = swept; + } + } + } + const status = liveFile?.status || 'ready'; + const payload = { file_id, status }; + if (status === 'ready' && liveFile?.text != null) { + payload.text = liveFile.text; + payload.textFormat = liveFile.textFormat ?? null; + } else if (status === 'failed' && liveFile?.previewError) { + payload.previewError = liveFile.previewError; + } + res.set('Cache-Control', 'private, no-store'); + return res.status(200).json(payload); + } catch (error) { + logger.error('[shareFileAccess] Error fetching shared preview:', error); + return res.status(500).json({ message: 'Error fetching preview' }); + } + }, + ); + + /** Download a snapshotted file (attachment disposition). */ + router.get( + '/:shareId/files/:file_id/download', + optionalJwtAuth, + optionalShareFileAuth, + canAccessSharedLink, + configMiddleware, + resolveShareFile, + async (req, res) => { + try { + await runWithTenant(req.shareFile.tenantId, () => + streamSharedFile(req, res, req.shareFile, 'attachment'), + ); + } catch (error) { + logger.error('[shareFileAccess] Error downloading shared file:', error); + if (!res.headersSent) { + res.status(500).send('Error downloading file'); + } + } + }, + ); + + /** Inline-serve a snapshotted file (image src, generic view). */ + router.get( + '/:shareId/files/:file_id', + optionalJwtAuth, + optionalShareFileAuth, + canAccessSharedLink, + configMiddleware, + resolveShareFile, + async (req, res) => { + try { + await runWithTenant(req.shareFile.tenantId, () => + streamSharedFile(req, res, req.shareFile, 'inline'), + ); + } catch (error) { + logger.error('[shareFileAccess] Error serving shared file:', error); + if (!res.headersSent) { + res.status(500).send('Error serving file'); + } + } + }, + ); } /** @@ -121,6 +380,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { success: share.success, shareId: share.shareId, targetMessageId: share.targetMessageId, + snapshotFiles: share.snapshotFiles, conversationId: req.params.conversationId, }); } catch (error) { @@ -129,37 +389,47 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { } }); -router.post('/:conversationId', requireJwtAuth, checkSharedLinksAccess, async (req, res) => { - try { - const { targetMessageId } = req.body; - const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId); - if (expiredAt != null && !isActiveExpirationDate(expiredAt)) { - return res.status(404).end(); +router.post( + '/:conversationId', + requireJwtAuth, + configMiddleware, + checkSharedLinksAccess, + async (req, res) => { + try { + const { targetMessageId } = req.body; + const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId); + if (expiredAt != null && !isActiveExpirationDate(expiredAt)) { + return res.status(404).end(); + } + + const role = await getRoleByName(req.user.role); + const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {}; + const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true; + // Per-link opt-out: snapshot only when the feature is enabled AND the user + // did not uncheck "share files" (body flag absent defaults to enabled). + const snapshotFiles = isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false; + + const created = await createSharedLink( + req.user.id, + req.params.conversationId, + targetMessageId, + expiredAt, + snapshotFiles, + ); + if (created) { + await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt); + res.status(200).json(created); + } else { + res.status(404).end(); + } + } catch (error) { + logger.error('Error creating shared link:', error); + res.status(500).json({ message: 'Error creating shared link' }); } + }, +); - const role = await getRoleByName(req.user.role); - const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {}; - const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true; - - const created = await createSharedLink( - req.user.id, - req.params.conversationId, - targetMessageId, - expiredAt, - ); - if (created) { - await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt); - res.status(200).json(created); - } else { - res.status(404).end(); - } - } catch (error) { - logger.error('Error creating shared link:', error); - res.status(500).json({ message: 'Error creating shared link' }); - } -}); - -router.patch('/:shareId', requireJwtAuth, async (req, res) => { +router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => { try { const { targetMessageId } = req.body ?? {}; if (targetMessageId !== undefined && typeof targetMessageId !== 'string') { @@ -184,6 +454,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => { req.params.shareId, targetMessageId, expiredAt, + isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false, ); if (updatedShare) { if (updatedShare._id && expiredAt !== undefined) { diff --git a/client/src/Providers/ShareContext.tsx b/client/src/Providers/ShareContext.tsx index fc5a1db00a..74caf07cb9 100644 --- a/client/src/Providers/ShareContext.tsx +++ b/client/src/Providers/ShareContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -type TShareContext = { isSharedConvo?: boolean }; +type TShareContext = { isSharedConvo?: boolean; shareId?: string }; export const ShareContext = createContext({} as TShareContext); export const useShareContext = () => useContext(ShareContext); diff --git a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx index 45881749f7..79886c5d73 100644 --- a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx +++ b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx @@ -3,9 +3,10 @@ import copy from 'copy-to-clipboard'; import { useRecoilValue } from 'recoil'; import { Download } from 'lucide-react'; import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client'; -import CopyButton from '~/components/Messages/Content/CopyButton'; +import { useFileDownload, useSharedFileDownload } from '~/data-provider'; import { logger, sortPagesByRelevance, triggerDownload } from '~/utils'; -import { useFileDownload } from '~/data-provider'; +import CopyButton from '~/components/Messages/Content/CopyButton'; +import { useShareContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -14,6 +15,7 @@ interface FilePreviewDialogProps { onOpenChange: (open: boolean) => void; fileName: string; fileId?: string; + filePath?: string; relevance?: number; pages?: number[]; pageRelevance?: Record; @@ -128,6 +130,7 @@ export default function FilePreviewDialog({ onOpenChange, fileName, fileId, + filePath, relevance, pages, pageRelevance, @@ -136,7 +139,13 @@ export default function FilePreviewDialog({ }: FilePreviewDialogProps) { const localize = useLocalize(); const user = useRecoilValue(store.user); - const { refetch: downloadFile } = useFileDownload(user?.id ?? '', fileId, { direct: false }); + const { shareId } = useShareContext(); + const { refetch: downloadOwned } = useFileDownload(user?.id ?? '', fileId, { direct: false }); + const { refetch: downloadShared } = useSharedFileDownload(shareId, fileId); + // Use the share route only for snapshotted files (filepath rewritten to the + // share path); otherwise fall back to the owner route. + const useShared = !!shareId && (filePath?.startsWith('/api/share/') ?? false); + const downloadFile = useShared ? downloadShared : downloadOwned; const [fileContent, setFileContent] = useState(null); const [fileBlobUrl, setFileBlobUrl] = useState(null); diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 176507edc1..4e4d85060c 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -46,6 +46,7 @@ const Files = ({ message }: { message?: TMessage }) => { onOpenChange={handleClose} fileName={selectedFile?.filename ?? ''} fileId={selectedFile?.file_id} + filePath={selectedFile?.filepath} fileType={selectedFile?.type ?? undefined} fileSize={(selectedFile as TFile)?.bytes} /> diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index 7e3e12e65b..070d379c7c 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -51,16 +51,17 @@ const Image = ({ const absoluteImageUrl = useMemo(() => { if (!imagePath) return imagePath; - if ( - imagePath.startsWith('http') || - imagePath.startsWith('data:') || - !imagePath.startsWith('/images/') - ) { + if (imagePath.startsWith('http') || imagePath.startsWith('data:')) { return imagePath; } - const baseURL = apiBaseUrl(); - return `${baseURL}${imagePath}`; + // Root-relative server paths (`/images/...` static, `/api/share/...` share + // routes) are resolved against the API base so they load under a subpath. + if (imagePath.startsWith('/images/') || imagePath.startsWith('/api/')) { + return `${apiBaseUrl()}${imagePath}`; + } + + return imagePath; }, [imagePath]); const downloadImage = async () => { diff --git a/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx b/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx index 50716f05c8..7d81a7c42e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { FileSources } from 'librechat-data-provider'; import { useToastContext } from '@librechat/client'; +import { FileSources, sharedFileDownload } from 'librechat-data-provider'; import { useCodeOutputDownload, useFileDownload } from '~/data-provider'; import { isHttpDownloadTarget, triggerDownload } from '~/utils'; +import { useShareContext } from '~/Providers'; interface LogLinkProps { href: string; @@ -47,6 +48,7 @@ export const useAttachmentLink = ({ source, }: AttachmentLinkOptions) => { const { showToast } = useToastContext(); + const { shareId } = useShareContext(); const useLocalDownload = isLocallyStoredSource(source) && !!file_id && !!user; const { refetch: downloadFromApi } = useFileDownload(user, file_id, { source }); @@ -55,6 +57,15 @@ export const useAttachmentLink = ({ const handleDownload = async (event: React.MouseEvent) => { event.preventDefault(); try { + // In a shared view, a snapshotted file's href is rewritten to the share + // route; download it through the share-scoped path (authorized by share + // permission, not owner ACL). Non-snapshotted files fall through so the + // original href / code-output path still works when snapshots are disabled. + if (shareId && file_id && href.startsWith('/api/share/')) { + triggerDownload(sharedFileDownload(shareId, file_id), filename); + return; + } + if (!useLocalDownload && isHttpDownloadTarget(href)) { triggerDownload(href, filename); return; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx index 6b408acb33..f4e1257a70 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { FileSources } from 'librechat-data-provider'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import LogLink from '../LogLink'; const mockShowToast = jest.fn(); const mockDownloadFromApi = jest.fn(); const mockDownloadFromUrl = jest.fn(); const mockTriggerDownload = jest.fn(); +let mockShareContext: { shareId?: string } = {}; jest.mock('@librechat/client', () => ({ useToastContext: () => ({ showToast: mockShowToast }), @@ -17,6 +18,10 @@ jest.mock('~/data-provider', () => ({ useCodeOutputDownload: () => ({ refetch: mockDownloadFromUrl }), })); +jest.mock('~/Providers', () => ({ + useShareContext: () => mockShareContext, +})); + jest.mock('~/utils', () => ({ isHttpDownloadTarget: (target?: string | null) => /^https?:\/\//i.test(target ?? ''), triggerDownload: (...args: Parameters) => @@ -26,6 +31,7 @@ jest.mock('~/utils', () => ({ describe('LogLink download routing', () => { beforeEach(() => { jest.clearAllMocks(); + mockShareContext = {}; }); it('navigates directly to http URLs when no stored file metadata is available', async () => { @@ -76,6 +82,28 @@ describe('LogLink download routing', () => { expect(mockDownloadFromUrl).not.toHaveBeenCalled(); }); + it('routes downloads through the share-scoped route in a shared view', async () => { + mockShareContext = { shareId: 'share-9' }; + const filename = 'file.pdf'; + + render( + + {filename} + , + ); + + fireEvent.click(screen.getByRole('link', { name: filename })); + + await waitFor(() => { + expect(mockTriggerDownload).toHaveBeenCalledWith( + '/api/share/share-9/files/file-1/download', + 'file.pdf', + ); + }); + expect(mockDownloadFromApi).not.toHaveBeenCalled(); + expect(mockDownloadFromUrl).not.toHaveBeenCalled(); + }); + it('keeps legacy code-output handles on the blob download path', async () => { const filename = 'legacy.txt'; mockDownloadFromUrl.mockResolvedValue({ data: 'blob:https://app.example.com/file' }); diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 2161e53923..d1dfeb162d 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { Copy, CopyCheck } from 'lucide-react'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; -import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client'; -import { useLocalize, useCopyToClipboard } from '~/hooks'; +import { OGDialogTemplate, Button, Spinner, OGDialog, Checkbox, Label } from '@librechat/client'; import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; +import { useLocalize, useCopyToClipboard } from '~/hooks'; +import { useGetStartupConfig } from '~/data-provider'; import SharedLinkButton from './SharedLinkButton'; import { buildShareLinkUrl, cn } from '~/utils'; @@ -22,8 +23,11 @@ export default function ShareButton({ children?: React.ReactNode; }) { const localize = useLocalize(); + const { data: startupConfig } = useGetStartupConfig(); + const canSnapshotFiles = startupConfig?.sharedLinksSnapshotFilesEnabled === true; const [showQR, setShowQR] = useState(false); const [sharedLink, setSharedLink] = useState(''); + const [snapshotFiles, setSnapshotFiles] = useState(true); const [isCopying, setIsCopying] = useState(false); const [announcement, setAnnouncement] = useState(''); const copyLink = useCopyToClipboard({ text: sharedLink }); @@ -44,6 +48,14 @@ export default function ShareButton({ } }, [shareId]); + // Reflect an existing link's stored "share files" choice so the checkbox isn't + // misleading (legacy links have no stored choice → keep the default of enabled). + useEffect(() => { + if (share?.success === true && typeof share.snapshotFiles === 'boolean') { + setSnapshotFiles(share.snapshotFiles); + } + }, [share?.success, share?.snapshotFiles]); + const button = isLoading === true ? null : ( ); @@ -78,6 +91,33 @@ export default function ShareButton({ : localize('com_ui_share_create_message'); })()} + {canSnapshotFiles && isLoading !== true && ( +
+ setSnapshotFiles(checked === true)} + aria-label={localize('com_ui_share_files')} + className="mt-0.5" + /> +
+ + + {localize('com_ui_share_files_description')} + + {shareId && ( + + {localize('com_ui_share_files_refresh_note')} + + )} +
+
+ )}
{showQR && (
diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index 4cc36033d0..4455ed7b5f 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -37,6 +37,7 @@ export default function SharedLinkButton({ showQR, setShowQR, setSharedLink, + snapshotFiles, }: { share: TSharedLinkGetResponse | undefined; conversationId: string; @@ -44,6 +45,7 @@ export default function SharedLinkButton({ showQR: boolean; setShowQR: (showQR: boolean) => void; setSharedLink: (sharedLink: string) => void; + snapshotFiles?: boolean; }) { const localize = useLocalize(); const { showToast } = useToastContext(); @@ -99,7 +101,7 @@ export default function SharedLinkButton({ if (!shareId) { return; } - const updateShare = await mutateAsync({ shareId, targetMessageId }); + const updateShare = await mutateAsync({ shareId, targetMessageId, snapshotFiles }); const newLink = generateShareLink(updateShare.shareId); setSharedLink(newLink); setAnnouncement(localize('com_ui_link_refreshed')); @@ -109,7 +111,7 @@ export default function SharedLinkButton({ }; const createShareLink = async () => { - const share = await mutate({ conversationId, targetMessageId }); + const share = await mutate({ conversationId, targetMessageId, snapshotFiles }); const newLink = generateShareLink(share.shareId); setSharedLink(newLink); }; diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index 982eb36cd1..68c7a9cd75 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -148,7 +148,7 @@ function SharedView() { ); return ( - +
{artifactsContainer} diff --git a/client/src/data-provider/Files/queries.ts b/client/src/data-provider/Files/queries.ts index 37d6690bb2..81f161d628 100644 --- a/client/src/data-provider/Files/queries.ts +++ b/client/src/data-provider/Files/queries.ts @@ -118,6 +118,31 @@ export const useFileDownload = ( ); }; +/** + * Blob download for a snapshotted file served through a shared link. Authorized + * by shared-link view permission (public/ACL) rather than the owner's file ACL. + * Idle by default; call `refetch` to download. + */ +export const useSharedFileDownload = ( + shareId?: string, + file_id?: string, +): QueryObserverResult => { + return useQuery( + [QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? ''], + async () => { + if (!shareId || !file_id) { + return; + } + const response = await dataService.getSharedFileDownload(shareId, file_id); + return window.URL.createObjectURL(response.data); + }, + { + enabled: false, + retry: false, + }, + ); +}; + export const useCodeOutputDownload = (url = ''): QueryObserverResult => { return useQuery( [QueryKeys.fileDownload, url], @@ -157,6 +182,21 @@ export const fetchFilePreview = async (fileId: string): Promise } }; +/** Preview fetch for a snapshotted file served through a shared link. */ +export const fetchSharedFilePreview = async ( + shareId: string, + fileId: string, +): Promise => { + try { + const data = await dataService.getSharedFilePreview(shareId, fileId); + consecutivePreviewErrors.delete(fileId); + return data; + } catch (err) { + consecutivePreviewErrors.set(fileId, (consecutivePreviewErrors.get(fileId) ?? 0) + 1); + throw err; + } +}; + export const previewRefetchInterval = ( data: t.TFilePreview | undefined, query: { queryKey: readonly unknown[] }, @@ -194,10 +234,12 @@ export const _resetPreviewErrorCounter = (fileId?: string): void => { export const useFilePreview = ( file_id: string | undefined, config?: UseQueryOptions, + shareId?: string, ): QueryObserverResult => { return useQuery( - [QueryKeys.filePreview, file_id], - () => fetchFilePreview(file_id ?? ''), + shareId ? [QueryKeys.filePreview, file_id, shareId] : [QueryKeys.filePreview, file_id], + () => + shareId ? fetchSharedFilePreview(shareId, file_id ?? '') : fetchFilePreview(file_id ?? ''), { refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index cfebd10d17..f6aede97c8 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -172,24 +172,32 @@ export const usePinConversationMutation = ( export const useCreateSharedLinkMutation = ( options?: t.MutationOptions< t.TCreateShareLinkRequest, - { conversationId: string; targetMessageId?: string } + { conversationId: string; targetMessageId?: string; snapshotFiles?: boolean } >, ): UseMutationResult< t.TSharedLinkResponse, unknown, - { conversationId: string; targetMessageId?: string }, + { conversationId: string; targetMessageId?: string; snapshotFiles?: boolean }, unknown > => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => { + ({ + conversationId, + targetMessageId, + snapshotFiles, + }: { + conversationId: string; + targetMessageId?: string; + snapshotFiles?: boolean; + }) => { if (!conversationId) { throw new Error('Conversation ID is required'); } - return dataService.createSharedLink(conversationId, targetMessageId); + return dataService.createSharedLink(conversationId, targetMessageId, snapshotFiles); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { @@ -203,17 +211,25 @@ export const useCreateSharedLinkMutation = ( }; export const useUpdateSharedLinkMutation = ( - options?: t.MutationOptions, -): UseMutationResult => { + options?: t.MutationOptions< + t.TUpdateShareLinkRequest, + t.TUpdateShareLinkRequest & { snapshotFiles?: boolean } + >, +): UseMutationResult< + t.TSharedLinkResponse, + unknown, + t.TUpdateShareLinkRequest & { snapshotFiles?: boolean }, + unknown +> => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ shareId, targetMessageId }) => { + ({ shareId, targetMessageId, snapshotFiles }) => { if (!shareId) { throw new Error('Share ID is required'); } - return dataService.updateSharedLink(shareId, targetMessageId); + return dataService.updateSharedLink(shareId, targetMessageId, snapshotFiles); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { diff --git a/client/src/hooks/Files/useAttachmentPreviewSync.ts b/client/src/hooks/Files/useAttachmentPreviewSync.ts index d339118c20..05bb84985f 100644 --- a/client/src/hooks/Files/useAttachmentPreviewSync.ts +++ b/client/src/hooks/Files/useAttachmentPreviewSync.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider'; import { useFilePreview } from '~/data-provider'; +import { useShareContext } from '~/Providers'; import store from '~/store'; interface UseAttachmentPreviewSyncResult { @@ -99,9 +100,10 @@ export default function useAttachmentPreviewSync( const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready'; const messageId = (attachment as Partial | undefined)?.messageId; + const { shareId } = useShareContext(); const enabled = !!fileId && baseStatus === 'pending'; - const previewQuery = useFilePreview(fileId, { enabled }); + const previewQuery = useFilePreview(fileId, { enabled }, shareId); /* Effective status: prefer the polled record once it arrives, since * the SSE handler may have already moved the cache forward and the diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 5e81316ade..c39544bc91 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1595,6 +1595,9 @@ "com_ui_share_error": "There was an error sharing the chat link", "com_ui_share_everyone": "Share with everyone", "com_ui_share_everyone_description_var": "This {{resource}} will be available to everyone. Please make sure the {{resource}} is really meant to be shared with everyone. Be careful with your data.", + "com_ui_share_files": "Share files in this conversation", + "com_ui_share_files_description": "Images and files in this conversation won't be visible to viewers unless this is enabled.", + "com_ui_share_files_refresh_note": "Refresh the link to apply this change — files are snapshotted when the link is refreshed.", "com_ui_share_link_to_chat": "Share link to chat", "com_ui_share_qr_code_description": "QR code for sharing this conversation link", "com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.", diff --git a/librechat.example.yaml b/librechat.example.yaml index 7a6a58c4e6..bfdcc60148 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -192,6 +192,7 @@ interface: # create: false # share: true # public: true # Allows users to toggle "share with everyone" for their links. Whether anonymous access is permitted is controlled by ALLOW_SHARED_LINKS_PUBLIC. + # snapshotFiles: true # Snapshot files referenced by a shared chat so viewers can preview/download them via the link. Enabled by default; the SHARED_LINKS_SNAPSHOT_FILES env var overrides this. # mcpServers: # Controls user permissions for MCP (Model Context Protocol) server management # - use: Allow users to use configured MCP servers diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0e4db579e0..ead04e0b87 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -68,6 +68,7 @@ export * from './cache'; /* Shared Links */ export * from './shared-links/access'; export * from './shared-links/service'; +export * from './shared-links/config'; /* Stream */ export * from './stream'; /* Diagnostics */ diff --git a/packages/api/src/shared-links/access.ts b/packages/api/src/shared-links/access.ts index dbbf43a3a4..d83c5fd24d 100644 --- a/packages/api/src/shared-links/access.ts +++ b/packages/api/src/shared-links/access.ts @@ -1,8 +1,8 @@ -import { getTenantId, runAsSystem, tenantStorage } from '@librechat/data-schemas'; import { ResourceType, PermissionBits } from 'librechat-data-provider'; +import { getTenantId, runAsSystem, tenantStorage } from '@librechat/data-schemas'; import type { Request, Response, NextFunction } from 'express'; -import type { Types, Model } from 'mongoose'; import type { IUser } from '@librechat/data-schemas'; +import type { Types, Model } from 'mongoose'; import { AccessControlService } from '~/acl/accessControlService'; import { autoMigrateLegacyLink } from './service'; import { isEnabled } from '~/utils'; diff --git a/packages/api/src/shared-links/config.test.ts b/packages/api/src/shared-links/config.test.ts new file mode 100644 index 0000000000..12d4c3d23f --- /dev/null +++ b/packages/api/src/shared-links/config.test.ts @@ -0,0 +1,43 @@ +import type { AppConfig } from '@librechat/data-schemas'; +import { isFileSnapshotEnabled } from './config'; + +const withSharedLinks = (sharedLinks: unknown): AppConfig => + ({ interfaceConfig: { sharedLinks } }) as unknown as AppConfig; + +describe('isFileSnapshotEnabled', () => { + const original = process.env.SHARED_LINKS_SNAPSHOT_FILES; + + afterEach(() => { + if (original === undefined) { + delete process.env.SHARED_LINKS_SNAPSHOT_FILES; + } else { + process.env.SHARED_LINKS_SNAPSHOT_FILES = original; + } + }); + + it('defaults to enabled with no config or env', () => { + delete process.env.SHARED_LINKS_SNAPSHOT_FILES; + expect(isFileSnapshotEnabled()).toBe(true); + expect(isFileSnapshotEnabled({} as AppConfig)).toBe(true); + }); + + it('honors yaml snapshotFiles: false', () => { + delete process.env.SHARED_LINKS_SNAPSHOT_FILES; + expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: false }))).toBe(false); + }); + + it('defaults enabled when sharedLinks is a boolean', () => { + delete process.env.SHARED_LINKS_SNAPSHOT_FILES; + expect(isFileSnapshotEnabled(withSharedLinks(true))).toBe(true); + }); + + it('env override wins over yaml (env false beats yaml true)', () => { + process.env.SHARED_LINKS_SNAPSHOT_FILES = 'false'; + expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: true }))).toBe(false); + }); + + it('env override wins over yaml (env true beats yaml false)', () => { + process.env.SHARED_LINKS_SNAPSHOT_FILES = 'true'; + expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: false }))).toBe(true); + }); +}); diff --git a/packages/api/src/shared-links/config.ts b/packages/api/src/shared-links/config.ts new file mode 100644 index 0000000000..cd9766b763 --- /dev/null +++ b/packages/api/src/shared-links/config.ts @@ -0,0 +1,33 @@ +import type { AppConfig } from '@librechat/data-schemas'; +import { isEnabled } from '~/utils'; + +/** + * Whether shared links should snapshot the files referenced by the shared chat + * snapshot. The `SHARED_LINKS_SNAPSHOT_FILES` env var overrides the yaml + * `interface.sharedLinks.snapshotFiles` value; both default to enabled. + */ +export function isFileSnapshotEnabled(appConfig?: AppConfig): boolean { + const envValue = process.env.SHARED_LINKS_SNAPSHOT_FILES; + if (envValue !== undefined) { + return isEnabled(envValue); + } + + const sharedLinks = appConfig?.interfaceConfig?.sharedLinks; + if (sharedLinks && typeof sharedLinks === 'object') { + return sharedLinks.snapshotFiles !== false; + } + + return true; +} + +/** + * Viewer-independent global kill switch for serving shared-link files. Reading + * and serving must NOT depend on the viewer's resolved config (per-role/user + * overrides) — only on the link's own stored choice plus this global env switch. + * Active only when `SHARED_LINKS_SNAPSHOT_FILES` is explicitly set to a disabled + * value; the creator's yaml choice is already captured per-link at share time. + */ +export function isFileSnapshotKillSwitchActive(): boolean { + const envValue = process.env.SHARED_LINKS_SNAPSHOT_FILES; + return envValue !== undefined && !isEnabled(envValue); +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index ddcd41bc1f..bf3d3031bd 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -84,6 +84,13 @@ export const getSharedLinks = ( }${cursor ? `&cursor=${cursor}` : ''}`; export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`; export const updateSharedLink = (shareId: string) => `${shareRoot}/${shareId}`; +/** Share-scoped file routes: serve snapshotted files via shared-link permission. */ +export const sharedFile = (shareId: string, fileId: string) => + `${shareRoot}/${shareId}/files/${encodeURIComponent(fileId)}`; +export const sharedFileDownload = (shareId: string, fileId: string) => + `${sharedFile(shareId, fileId)}/download`; +export const sharedFilePreview = (shareId: string, fileId: string) => + `${sharedFile(shareId, fileId)}/preview`; const keysEndpoint = `${BASE_URL}/api/keys`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c10597f53f..eafcc2f97f 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1248,6 +1248,7 @@ export const interfaceSchema = z create: z.boolean().optional(), share: z.boolean().optional(), public: z.boolean().optional(), + snapshotFiles: z.boolean().optional(), }), ]) .optional(), @@ -1311,6 +1312,7 @@ export const interfaceSchema = z create: true, share: true, public: true, + snapshotFiles: true, }, }); @@ -1391,6 +1393,8 @@ export type TStartupConfig = { modelDescriptions?: Record>; sharedLinksEnabled: boolean; publicSharedLinksEnabled: boolean; + /** Whether shared links snapshot conversation files (gates the per-link "share files" checkbox). */ + sharedLinksSnapshotFilesEnabled?: boolean; /** Effective default timing for when conversation titles become fetchable. * `immediate` = fetch in parallel with the active stream (default); * `final` = fetch only after the stream completes (legacy). */ diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 8864bc2af6..1ffac23dd4 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -79,15 +79,20 @@ export function getSharedLink(conversationId: string): Promise { - return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); + return request.post(endpoints.createSharedLink(conversationId), { + targetMessageId, + snapshotFiles, + }); } export function updateSharedLink( shareId: string, targetMessageId?: string, + snapshotFiles?: boolean, ): Promise { - return request.patch(endpoints.updateSharedLink(shareId), { targetMessageId }); + return request.patch(endpoints.updateSharedLink(shareId), { targetMessageId, snapshotFiles }); } export function deleteSharedLink(shareId: string): Promise { @@ -437,6 +442,11 @@ export const getFilePreview = (fileId: string): Promise => { return request.get(endpoints.filePreview(fileId)); }; +/** Preview status for a snapshotted file served through a shared link. */ +export const getSharedFilePreview = (shareId: string, fileId: string): Promise => { + return request.get(endpoints.sharedFilePreview(shareId, fileId)); +}; + export const getAgentFiles = (agentId: string): Promise => { return request.get(endpoints.agentFiles(agentId)); }; @@ -723,6 +733,19 @@ export const getFileDownloadURL = async ( return request.get(`${endpoints.files()}/download-url/${userId}/${file_id}`); }; +/** Blob download for a snapshotted file served through a shared link. */ +export const getSharedFileDownload = async ( + shareId: string, + file_id: string, +): Promise => { + return request.getResponse(endpoints.sharedFileDownload(shareId, file_id), { + responseType: 'blob', + headers: { + Accept: 'application/octet-stream', + }, + }); +}; + export const getCodeOutputDownload = async (url: string): Promise => { return request.getResponse(url, { responseType: 'blob', diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index bb166d975a..7ecaf4ba8a 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -36,7 +36,13 @@ export * from './accessPermissions'; export * from './keys'; /* api call helpers */ export * from './headers-helpers'; -export { loginPage, registerPage, apiBaseUrl, buildLoginRedirectUrl } from './api-endpoints'; +export { + loginPage, + registerPage, + apiBaseUrl, + sharedFileDownload, + buildLoginRedirectUrl, +} from './api-endpoints'; export { default as request } from './request'; export { dataService }; import * as dataService from './data-service'; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 7cbafbb536..020f6c637d 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -377,6 +377,8 @@ export type TSharedLinkResponse = Pick & export type TSharedLinkGetResponse = Omit & { shareId: string | null; success: boolean; + /** Per-link "share files" choice; absent on legacy links (treated as enabled). */ + snapshotFiles?: boolean; }; // type for getting conversation tags diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts index d1ffc78c83..63b1ba207c 100644 --- a/packages/data-schemas/src/methods/share.test.ts +++ b/packages/data-schemas/src/methods/share.test.ts @@ -2,9 +2,9 @@ import { nanoid } from 'nanoid'; import mongoose from 'mongoose'; import { Constants } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { createShareMethods, type ShareMethods } from './share'; import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type * as t from '~/types'; +import { createShareMethods, type ShareMethods } from './share'; describe('Share Methods', () => { let mongoServer: MongoMemoryServer; @@ -12,6 +12,7 @@ describe('Share Methods', () => { let SharedLink: mongoose.Model; let Message: mongoose.Model; let Conversation: SchemaWithMeiliMethods; + let File: mongoose.Model; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); @@ -28,6 +29,29 @@ describe('Share Methods', () => { shareId: { type: String, index: true }, targetMessageId: { type: String, required: false, index: true }, expiredAt: { type: Date }, + snapshotFiles: { type: Boolean }, + fileSnapshots: { type: [mongoose.Schema.Types.Mixed], default: undefined }, + }, + { timestamps: true }, + ); + + const fileSchema = new mongoose.Schema( + { + user: { type: String, required: true }, + file_id: { type: String, required: true, index: true }, + filename: { type: String, required: true }, + filepath: { type: String, required: true }, + storageKey: String, + type: String, + bytes: Number, + source: String, + width: Number, + height: Number, + text: String, + textFormat: { type: String, enum: ['html', 'text'] }, + status: { type: String, enum: ['pending', 'ready', 'failed'] }, + previewError: String, + tenantId: String, }, { timestamps: true }, ); @@ -75,6 +99,7 @@ describe('Share Methods', () => { 'Conversation', conversationSchema, )) as SchemaWithMeiliMethods; + File = mongoose.models.File || mongoose.model('File', fileSchema); // Create share methods shareMethods = createShareMethods(mongoose); @@ -89,6 +114,7 @@ describe('Share Methods', () => { await SharedLink.deleteMany({}); await Message.deleteMany({}); await Conversation.deleteMany({}); + await File.deleteMany({}); }); describe('createSharedLink', () => { @@ -486,10 +512,13 @@ describe('Share Methods', () => { expect(shared?.manualSkills).toEqual(['research']); expect(shared?.alwaysAppliedSkills).toEqual(['brand-voice']); - // User-uploaded files keep their render URL (filepath/preview) but drop storage internals. + // Render metadata (filename/type/dims) is kept, storage internals dropped. The + // file isn't snapshotted (no backing File record), so its original render URL is + // neutralized — viewers can only load files through the authorized share route. const file = shared?.files?.[0]; expect(file).toMatchObject({ filename: 'upload.png', type: 'image/png' }); - expect(file?.filepath).toBe('/images/upload.png'); + expect(file).not.toHaveProperty('filepath'); + expect(file).not.toHaveProperty('preview'); expect(file).not.toHaveProperty('storageKey'); expect(file).not.toHaveProperty('user'); expect(file).not.toHaveProperty('tenantId'); @@ -498,15 +527,15 @@ describe('Share Methods', () => { expect(file?.conversationId).toBe(shared?.conversationId); expect(file?.conversationId).not.toBe(conversationId); - // Tool-call attachments keep their correlation id, payload, and render URL so - // citations still render, while storage-only fields are removed. + // Tool-call attachments keep their correlation id and payload so citations still + // render, while storage-only fields AND the original render URL are removed. const attachment = shared?.attachments?.[0]; expect(attachment).toMatchObject({ toolCallId: 'call_abc', type: 'web_search', web_search: { results: [{ title: 'Cited source', link: 'https://example.com' }] }, - filepath: '/images/result.json', }); + expect(attachment).not.toHaveProperty('filepath'); expect(attachment).not.toHaveProperty('storageKey'); expect(attachment).not.toHaveProperty('metadata'); }); @@ -1507,4 +1536,440 @@ describe('Share Methods', () => { expect(result?.messages[0].parentMessageId).toBe(Constants.NO_PARENT); }); }); + + describe('file snapshots', () => { + const seedConversation = async (userId: string, conversationId: string) => { + await Conversation.create({ conversationId, title: 'Files Convo', user: userId }); + }; + + const createFile = async ( + userId: string, + overrides: Partial = {}, + ): Promise => { + const file_id = `file_${nanoid()}`; + await File.create({ + user: userId, + file_id, + filename: 'report.pdf', + filepath: `/uploads/${userId}/${file_id}`, + type: 'application/pdf', + bytes: 1024, + source: 'local', + ...overrides, + }); + return file_id; + }; + + test('createSharedLink captures snapshots from message files and attachments', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + + const imageId = await createFile(userId, { + type: 'image/png', + filename: 'pic.png', + filepath: `/images/${userId}/pic.png`, + width: 100, + height: 80, + }); + const docId = await createFile(userId); + + await Message.create([ + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'with image', + isCreatedByUser: true, + files: [{ file_id: imageId, type: 'image/png', filepath: `/images/${userId}/pic.png` }], + }, + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'with attachment', + isCreatedByUser: false, + attachments: [{ file_id: docId, type: 'application/pdf' }], + }, + ]); + + const result = await shareMethods.createSharedLink(userId, conversationId); + const saved = await SharedLink.findOne({ shareId: result.shareId }).lean(); + + expect(saved?.fileSnapshots).toHaveLength(2); + const byId = new Map(saved?.fileSnapshots?.map((s) => [s.file_id, s])); + expect(byId.get(imageId)?.source).toBe('local'); + expect(byId.get(imageId)?.storageKey).toBeUndefined(); + expect(byId.get(docId)?.filename).toBe('report.pdf'); + expect(byId.get(docId)?.filepath).toBe(`/uploads/${userId}/${docId}`); + }); + + test('createSharedLink with snapshotFiles=false stores no snapshots', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'hi', + isCreatedByUser: true, + files: [{ file_id: docId }], + }); + + const result = await shareMethods.createSharedLink( + userId, + conversationId, + undefined, + undefined, + false, + ); + const saved = await SharedLink.findOne({ shareId: result.shareId }).lean(); + expect(saved?.fileSnapshots).toBeUndefined(); + }); + + test('snapshots skip non-streamable sources and missing file records', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + + const remoteId = await createFile(userId, { source: 'openai' }); + const ghostId = `file_${nanoid()}`; // referenced but no File doc + + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'hi', + isCreatedByUser: true, + files: [{ file_id: remoteId }, { file_id: ghostId }], + }); + + const result = await shareMethods.createSharedLink(userId, conversationId); + const saved = await SharedLink.findOne({ shareId: result.shareId }).lean(); + expect(saved?.fileSnapshots ?? []).toHaveLength(0); + }); + + test('getSharedMessages rewrites snapshotted file URLs to the share route', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc', + isCreatedByUser: true, + files: [ + { file_id: docId, type: 'application/pdf', filepath: `/uploads/${userId}/${docId}` }, + ], + }); + + const { shareId } = await shareMethods.createSharedLink(userId, conversationId); + const result = await shareMethods.getSharedMessages(shareId); + + const file = (result?.messages[0].files?.[0] ?? {}) as Record; + expect(file.filepath).toBe(`/api/share/${shareId}/files/${docId}`); + // owner storage path must not leak + expect(String(file.filepath)).not.toContain(userId); + }); + + test('getSharedMessages neutralizes URLs for non-snapshotted files', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const remoteId = await createFile(userId, { source: 'openai' }); + const originalPath = `/uploads/${userId}/${remoteId}`; + + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc', + isCreatedByUser: true, + files: [{ file_id: remoteId, filepath: originalPath }], + }); + + const { shareId } = await shareMethods.createSharedLink(userId, conversationId); + const result = await shareMethods.getSharedMessages(shareId); + const file = (result?.messages[0].files?.[0] ?? {}) as Record; + // Non-snapshotted (non-streamable source): original URL must not leak. + expect(file.filepath).toBeUndefined(); + expect(file.preview).toBeUndefined(); + }); + + test('updateSharedLink recomputes snapshots from current messages', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'no files yet', + isCreatedByUser: true, + }); + + const created = await shareMethods.createSharedLink(userId, conversationId); + let saved = await SharedLink.findOne({ shareId: created.shareId }).lean(); + expect(saved?.fileSnapshots ?? []).toHaveLength(0); + + const docId = await createFile(userId); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'now with a file', + isCreatedByUser: false, + files: [{ file_id: docId }], + }); + + const updated = await shareMethods.updateSharedLink(userId, created.shareId); + saved = await SharedLink.findOne({ shareId: updated.shareId }).lean(); + expect(saved?.fileSnapshots).toHaveLength(1); + expect(saved?.fileSnapshots?.[0].file_id).toBe(docId); + }); + + test('getSharedLinkFile returns the entry, null for unknown files', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc', + isCreatedByUser: true, + files: [{ file_id: docId }], + }); + + const { shareId } = await shareMethods.createSharedLink(userId, conversationId); + + const found = await shareMethods.getSharedLinkFile(shareId, docId); + expect(found.file?.file_id).toBe(docId); + expect(found.hasSnapshots).toBe(true); + + const missing = await shareMethods.getSharedLinkFile(shareId, 'file_does_not_exist'); + expect(missing.file).toBeNull(); + expect(missing.hasSnapshots).toBe(true); + }); + + test('backfillSharedLinkFiles populates a legacy share missing snapshots', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + const message = await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc', + isCreatedByUser: true, + files: [{ file_id: docId }], + }); + + const shareId = `share_${nanoid()}`; + // legacy share: no fileSnapshots + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [message._id], + }); + + const before = await shareMethods.getSharedLinkFile(shareId, docId); + expect(before.file).toBeNull(); + expect(before.hasSnapshots).toBe(false); + + const backfilled = await shareMethods.backfillSharedLinkFiles(shareId, docId); + expect((backfilled as t.SharedFileSnapshot)?.file_id).toBe(docId); + + const saved = await SharedLink.findOne({ shareId }).lean(); + expect(saved?.fileSnapshots).toHaveLength(1); + }); + + test('does not snapshot a file owned by another user', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const otherUserId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + // File belongs to another user but is referenced in the sharer's message. + const victimId = await createFile(otherUserId); + + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'borrowed file id', + isCreatedByUser: true, + files: [{ file_id: victimId }], + }); + + const result = await shareMethods.createSharedLink(userId, conversationId); + const saved = await SharedLink.findOne({ shareId: result.shareId }).lean(); + expect(saved?.fileSnapshots ?? []).toHaveLength(0); + }); + + test('getSharedMessages strips files when the admin feature is disabled', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + const originalPath = `/uploads/${userId}/${docId}`; + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc', + isCreatedByUser: true, + files: [{ file_id: docId, filepath: originalPath }], + }); + + const { shareId } = await shareMethods.createSharedLink(userId, conversationId); + const result = await shareMethods.getSharedMessages(shareId, undefined, { + snapshotFiles: false, + }); + // Files are stripped entirely, not just left unrewritten — no owner path leaks. + expect(result?.messages[0].files).toBeUndefined(); + }); + + test('createSharedLink with snapshotFiles=false strips files and skips snapshots', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + const originalPath = `/uploads/${userId}/${docId}`; + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc with file', + isCreatedByUser: true, + files: [{ file_id: docId, type: 'image/png', filepath: originalPath }], + }); + + // User opts out of sharing files for this link. + const { shareId } = await shareMethods.createSharedLink( + userId, + conversationId, + undefined, + undefined, + false, + ); + + const saved = await SharedLink.findOne({ shareId }).lean(); + expect(saved?.snapshotFiles).toBe(false); + expect(saved?.fileSnapshots).toBeUndefined(); + + // Even with the admin feature on, an opted-out link shows no files and is not + // backfilled on read (the prior bug: original paths leaked / snapshots re-created). + const result = await shareMethods.getSharedMessages(shareId, undefined, { + snapshotFiles: true, + }); + expect(result?.messages[0].files).toBeUndefined(); + + const after = await SharedLink.findOne({ shareId }).lean(); + expect(after?.fileSnapshots).toBeUndefined(); + }); + + test('getSharedLink surfaces the per-link snapshotFiles choice', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'hi', + isCreatedByUser: true, + }); + + await shareMethods.createSharedLink(userId, conversationId, undefined, undefined, false); + const link = await shareMethods.getSharedLink(userId, conversationId); + expect(link.snapshotFiles).toBe(false); + }); + + test('getSharedMessages backfills and rewrites a legacy share on read', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + const message = await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc', + isCreatedByUser: true, + files: [{ file_id: docId, filepath: `/uploads/${userId}/${docId}` }], + }); + + const shareId = `share_${nanoid()}`; + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [message._id], + }); + + const result = await shareMethods.getSharedMessages(shareId); + const file = (result?.messages[0].files?.[0] ?? {}) as Record; + expect(file.filepath).toBe(`/api/share/${shareId}/files/${docId}`); + + // snapshot persisted by the lazy backfill + const saved = await SharedLink.findOne({ shareId }).lean(); + expect(saved?.fileSnapshots).toHaveLength(1); + }); + + test('does not snapshot transient text-source files', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const textId = await createFile(userId, { source: 'text' }); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'rag context', + isCreatedByUser: true, + files: [{ file_id: textId }], + }); + + const result = await shareMethods.createSharedLink(userId, conversationId); + const saved = await SharedLink.findOne({ shareId: result.shareId }).lean(); + expect(saved?.fileSnapshots ?? []).toHaveLength(0); + }); + + test('updateSharedLink clears snapshots when snapshotFiles is disabled', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + await seedConversation(userId, conversationId); + const docId = await createFile(userId); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'doc', + isCreatedByUser: true, + files: [{ file_id: docId }], + }); + + const created = await shareMethods.createSharedLink(userId, conversationId); + let saved = await SharedLink.findOne({ shareId: created.shareId }).lean(); + expect(saved?.fileSnapshots).toHaveLength(1); + + const updated = await shareMethods.updateSharedLink( + userId, + created.shareId, + undefined, + undefined, + false, + ); + saved = await SharedLink.findOne({ shareId: updated.shareId }).lean(); + expect(saved?.fileSnapshots).toBeUndefined(); + }); + }); }); diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 88a328644b..5bb297f197 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -1,5 +1,5 @@ import { nanoid } from 'nanoid'; -import { Constants } from 'librechat-data-provider'; +import { Constants, FileSources } from 'librechat-data-provider'; import type { FilterQuery, Model } from 'mongoose'; import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type * as t from '~/types'; @@ -98,6 +98,122 @@ function sanitizeSharedFiles(files: unknown): t.SharedFile[] | undefined { return sanitized.length > 0 ? sanitized : undefined; } +/** + * Sources backed by a durable stored object that the share-scoped routes can + * stream with only `storageKey`/`filepath` + the request. Sources requiring + * owner-specific credentials (openai/azure assistants, execute_code, vectordb, + * OCR/parser pipelines) are skipped — those files degrade to a 404 in the share + * view. `FileSources.text` is intentionally excluded: its `filepath` is a Multer + * temp path that the upload route deletes, so there is nothing durable to stream. + */ +const SNAPSHOT_STREAMABLE_SOURCES = new Set([ + FileSources.local, + FileSources.s3, + FileSources.cloudfront, + FileSources.azure_blob, + FileSources.firebase, +]); + +/** Collect `file_id`s from a message's `files`/`attachments` array into `target`. */ +function collectFileIds(items: unknown, target: Set): void { + if (!Array.isArray(items)) { + return; + } + for (const item of items) { + if (item && typeof item === 'object') { + const fileId = (item as { file_id?: unknown }).file_id; + if (typeof fileId === 'string' && fileId) { + target.add(fileId); + } + } + } +} + +/** + * Build the per-share file snapshot from the messages being shared. Captures only + * the metadata the share-scoped routes need to stream each file; references the + * original stored object (no byte copy). The lookup is scoped to the sharing + * user's own files so a message referencing another user's `file_id` can never + * widen access to it. Preview text/status is intentionally NOT embedded — it is + * read live from the file record so snapshots stay small and never go stale. + */ +async function buildFileSnapshots( + mongoose: typeof import('mongoose'), + messages: t.IMessage[], + ownerId?: string, +): Promise { + if (!ownerId) { + return []; + } + + const fileIds = new Set(); + for (const message of messages) { + collectFileIds(message.files, fileIds); + collectFileIds(message.attachments, fileIds); + } + + if (fileIds.size === 0) { + return []; + } + + const File = mongoose.models.File as Model; + const files = await File.find({ file_id: { $in: Array.from(fileIds) }, user: ownerId }).lean(); + + const snapshots: t.SharedFileSnapshot[] = []; + for (const file of files) { + const source = file.source ?? FileSources.local; + if (!SNAPSHOT_STREAMABLE_SOURCES.has(source)) { + continue; + } + snapshots.push({ + file_id: file.file_id, + source, + storageKey: file.storageKey, + filepath: file.filepath, + type: file.type, + filename: file.filename, + bytes: file.bytes, + width: file.width, + height: file.height, + model: file.model, + previewRevision: file.previewRevision, + tenantId: file.tenantId, + }); + } + return snapshots; +} + +/** Share-scoped file route that serves a snapshotted file independent of owner ACL. */ +function shareFileRoute(shareId: string, fileId: string): string { + return `/api/share/${shareId}/files/${encodeURIComponent(fileId)}`; +} + +/** + * Point a snapshotted file's render URLs at the share-scoped route so viewers load + * it through the authorized share path (and the owner's storage path is not leaked). + */ +function applyShareFileRoute( + file: t.SharedFile, + shareId: string, + snapshotIds: Set, +): t.SharedFile { + const fileId = file.file_id; + if (typeof fileId === 'string' && snapshotIds.has(fileId)) { + const route = shareFileRoute(shareId, fileId); + const next: t.SharedFile = { ...file, filepath: route }; + if (file.preview !== undefined) { + next.preview = route; + } + return next; + } + // Not snapshotted (e.g. a non-streamable source on an included link): neutralize + // the render URLs so the owner's original path can't be loaded through the share. + const next: t.SharedFile = { ...file }; + delete next.filepath; + delete next.preview; + return next; +} + /** * Only surface a model name when it is an (already-anonymized) assistant id; * otherwise omit it so the underlying provider/model is not disclosed. @@ -117,7 +233,13 @@ function anonymizeSharedModel(model?: string): string | undefined { * field so render data (uploaded files, `toolCallId`, search results, generated * outputs) is preserved without leaking storage internals. */ -function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.SharedMessage[] { +function anonymizeMessages( + messages: t.IMessage[], + newConvoId: string, + shareId: string, + snapshotIds: Set, + includeFiles: boolean, +): t.SharedMessage[] { if (!Array.isArray(messages)) { return []; } @@ -127,18 +249,36 @@ function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.Shared const newMessageId = anonymizeMessageId(message.messageId); idMap.set(message.messageId, newMessageId); - const attachments = sanitizeSharedFiles(message.attachments)?.map((attachment) => ({ - ...attachment, - messageId: newMessageId, - conversationId: newConvoId, - })); + // When files are not shared for this link, omit files/attachments entirely so + // viewers can't load them through the owner's original (e.g. static) paths. + const attachments = includeFiles + ? sanitizeSharedFiles(message.attachments)?.map((attachment) => + applyShareFileRoute( + { + ...attachment, + messageId: newMessageId, + conversationId: newConvoId, + }, + shareId, + snapshotIds, + ), + ) + : undefined; // Persisted file records can carry the original conversation/message ids; // rewrite them to the anonymized ids so shared files don't expose them. - const files = sanitizeSharedFiles(message.files)?.map((file) => ({ - ...file, - ...(file.conversationId !== undefined && { conversationId: newConvoId }), - ...(file.messageId !== undefined && { messageId: newMessageId }), - })); + const files = includeFiles + ? sanitizeSharedFiles(message.files)?.map((file) => + applyShareFileRoute( + { + ...file, + ...(file.conversationId !== undefined && { conversationId: newConvoId }), + ...(file.messageId !== undefined && { messageId: newMessageId }), + }, + shareId, + snapshotIds, + ), + ) + : undefined; const model = anonymizeSharedModel(message.model); return { @@ -254,18 +394,29 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { conversationId: string, targetMessageId?: string, expiredAt?: Date, + snapshotFiles?: boolean, ) => Promise; updateSharedLink: ( user: string, shareId: string, targetMessageId?: string, expiredAt?: Date | null, + snapshotFiles?: boolean, ) => Promise; deleteSharedLink: (user: string, shareId: string) => Promise; getSharedMessages: ( shareId: string, shareObjectId?: string, + options?: { snapshotFiles?: boolean }, ) => Promise; + getSharedLinkFile: ( + shareId: string, + fileId: string, + ) => Promise<{ file: t.SharedFileSnapshot | null; hasSnapshots: boolean; optedOut: boolean }>; + backfillSharedLinkFiles: ( + shareId: string, + fileId?: string, + ) => Promise; deleteAllSharedLinks: ( user: string, ) => Promise; @@ -280,6 +431,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { async function getSharedMessages( shareId: string, shareObjectId?: string, + options?: { snapshotFiles?: boolean }, ): Promise { try { const SharedLink = mongoose.models.SharedLink as Model; @@ -292,7 +444,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { path: 'messages', select: '-_id -__v -user', }) - .select('-_id -__v -user') + .select('-__v') .lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null; if (!share?.conversationId) { @@ -306,13 +458,39 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { } const newConvoId = anonymizeConvoId(share.conversationId); + const resolvedShareId = share.shareId || shareId; + + /** + * Files are included only when the admin feature is enabled (options) AND the + * link's own choice wasn't opted out (`snapshotFiles === false`). When + * excluded, files/attachments are stripped from the payload so nothing leaks + * through the owner's original paths. Legacy links (no per-link choice and no + * snapshot yet) are backfilled here so their first view rewrites correctly. + */ + const adminEnabled = options?.snapshotFiles !== false; + const perLinkEnabled = share.snapshotFiles !== false; + const includeFiles = adminEnabled && perLinkEnabled; + let fileSnapshots = share.fileSnapshots; + if (includeFiles && fileSnapshots === undefined && share._id) { + fileSnapshots = await buildFileSnapshots(mongoose, messagesToShare, share.user); + await SharedLink.updateOne({ _id: share._id }, { $set: { fileSnapshots } }); + } + const snapshotIds = includeFiles + ? new Set((fileSnapshots ?? []).map((snapshot) => snapshot.file_id)) + : new Set(); const result: t.SharedMessagesResult = { - shareId: share.shareId || shareId, + shareId: resolvedShareId, title: share.title, createdAt: share.createdAt, updatedAt: share.updatedAt, conversationId: newConvoId, - messages: anonymizeMessages(messagesToShare, newConvoId), + messages: anonymizeMessages( + messagesToShare, + newConvoId, + resolvedShareId, + snapshotIds, + includeFiles, + ), }; return result; @@ -480,6 +658,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { conversationId: string, targetMessageId?: string, expiredAt?: Date, + snapshotFiles: boolean = true, ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -529,6 +708,17 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { const title = conversation.title || 'Untitled'; + const messagesForSnapshot = conversationMessages as unknown as t.IMessage[]; + const fileSnapshots = snapshotFiles + ? await buildFileSnapshots( + mongoose, + targetMessageId + ? getMessagesUpToTarget(messagesForSnapshot, targetMessageId) + : messagesForSnapshot, + user, + ) + : []; + const shareId = nanoid(); const created = await SharedLink.create({ shareId, @@ -536,8 +726,10 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { messages: conversationMessages, title, user, + snapshotFiles, ...(targetMessageId && { targetMessageId }), ...(expiredAt && { expiredAt }), + ...(snapshotFiles && { fileSnapshots }), }); return { _id: created._id.toString(), shareId, conversationId, targetMessageId }; @@ -573,11 +765,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { user, ...activeExpirationFilter(), }) - .select('shareId targetMessageId _id') + .select('shareId targetMessageId snapshotFiles _id') .sort({ updatedAt: -1 }) .lean()) as { shareId?: string; targetMessageId?: string; + snapshotFiles?: boolean; _id?: import('mongoose').Types.ObjectId; } | null; @@ -589,6 +782,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { _id: share._id?.toString(), shareId: share.shareId || null, targetMessageId: share.targetMessageId, + snapshotFiles: share.snapshotFiles, success: true, }; } catch (error) { @@ -609,6 +803,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { shareId: string, targetMessageId?: string, expiredAt?: Date | null, + snapshotFiles: boolean = true, ): Promise { if (!user || !shareId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -632,15 +827,33 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { const newShareId = nanoid(); const hasNewExpiration = expiredAt instanceof Date; const resolvedTargetMessageId = targetMessageId ?? share.targetMessageId; + const messagesForSnapshot = updatedMessages as unknown as t.IMessage[]; + const fileSnapshots = snapshotFiles + ? await buildFileSnapshots( + mongoose, + resolvedTargetMessageId + ? getMessagesUpToTarget(messagesForSnapshot, resolvedTargetMessageId) + : messagesForSnapshot, + user, + ) + : []; + // Clear any prior snapshot when snapshotting is off so a disabled-feature + // update can't keep serving stale file ids that the update dropped. + const unset = { + ...(expiredAt === null ? { expiredAt: 1 } : {}), + ...(snapshotFiles ? {} : { fileSnapshots: 1 }), + }; const update = { $set: { messages: updatedMessages, user, shareId: newShareId, + snapshotFiles, ...(resolvedTargetMessageId && { targetMessageId: resolvedTargetMessageId }), ...(hasNewExpiration && { expiredAt }), + ...(snapshotFiles && { fileSnapshots }), }, - ...(expiredAt === null ? { $unset: { expiredAt: 1 } } : {}), + ...(Object.keys(unset).length > 0 ? { $unset: unset } : {}), }; const updatedShare = (await SharedLink.findOneAndUpdate({ shareId, user }, update, { @@ -709,6 +922,79 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { } } + /** + * Resolve a single file snapshot entry for a shared link, used by the + * share-scoped file routes to authorize a file without the owner's ACL. + * `hasSnapshots` distinguishes a legacy share (field absent → caller may + * backfill) from an ordinary miss (field present but file not in it → 404, + * no rebuild). `optedOut` is the per-link "share files" choice — when true the + * route must 404 and never backfill, so an opted-out link can't expose files. + */ + async function getSharedLinkFile( + shareId: string, + fileId: string, + ): Promise<{ file: t.SharedFileSnapshot | null; hasSnapshots: boolean; optedOut: boolean }> { + const SharedLink = mongoose.models.SharedLink as Model; + const share = (await SharedLink.findOne({ + shareId, + ...activeExpirationFilter(), + }) + .select('fileSnapshots snapshotFiles') + .lean()) as Pick | null; + + if (!share) { + return { file: null, hasSnapshots: false, optedOut: false }; + } + + const hasSnapshots = share.fileSnapshots !== undefined; + const optedOut = share.snapshotFiles === false; + const file = share.fileSnapshots?.find((snapshot) => snapshot.file_id === fileId) ?? null; + return { file, hasSnapshots, optedOut }; + } + + /** + * Lazily build and persist the file snapshot for a legacy shared link that + * predates the feature. Mirrors the lazy migration done for legacy ACL grants. + * Returns the requested entry (or the full snapshot when no fileId is given). + */ + async function backfillSharedLinkFiles( + shareId: string, + fileId?: string, + ): Promise { + try { + const SharedLink = mongoose.models.SharedLink as Model; + const share = (await SharedLink.findOne({ + shareId, + ...activeExpirationFilter(), + }) + .populate({ path: 'messages', select: '-_id -__v -user' }) + .lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null; + + if (!share) { + return null; + } + + let messages: t.IMessage[] = share.messages ?? []; + if (share.targetMessageId) { + messages = getMessagesUpToTarget(messages, share.targetMessageId); + } + + const fileSnapshots = await buildFileSnapshots(mongoose, messages, share.user); + await SharedLink.updateOne({ shareId }, { $set: { fileSnapshots } }); + + if (fileId) { + return fileSnapshots.find((snapshot) => snapshot.file_id === fileId) ?? null; + } + return fileSnapshots; + } catch (error) { + logger.error('[backfillSharedLinkFiles] Error backfilling file snapshots', { + error: error instanceof Error ? error.message : 'Unknown error', + shareId, + }); + return null; + } + } + // Return all methods return { getSharedLink, @@ -717,6 +1003,8 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { updateSharedLink, deleteSharedLink, getSharedMessages, + getSharedLinkFile, + backfillSharedLinkFiles, deleteAllSharedLinks, deleteConvoSharedLink, }; diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 66eb3e808f..ca09857365 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -1,4 +1,5 @@ import mongoose, { Schema, Document, Types } from 'mongoose'; +import type { SharedFileSnapshot } from '~/types'; export interface ISharedLink extends Document { conversationId: string; @@ -11,8 +12,34 @@ export interface ISharedLink extends Document { createdAt?: Date; updatedAt?: Date; tenantId?: string; + snapshotFiles?: boolean; + fileSnapshots?: SharedFileSnapshot[]; } +/** + * Immutable file snapshot embedded on a shared link. Captures the metadata the + * share-scoped file routes need to stream/preview each referenced file without + * consulting the original owner's live file ACL. References the original stored + * object (no byte copy). + */ +const fileSnapshotSchema = new Schema( + { + file_id: { type: String, required: true }, + source: { type: String }, + storageKey: { type: String }, + filepath: { type: String }, + type: { type: String }, + filename: { type: String }, + bytes: { type: Number }, + width: { type: Number }, + height: { type: Number }, + model: { type: String }, + previewRevision: { type: String }, + tenantId: { type: String }, + }, + { _id: false }, +); + const shareSchema: Schema = new Schema( { conversationId: { @@ -44,6 +71,13 @@ const shareSchema: Schema = new Schema( expiredAt: { type: Date, }, + snapshotFiles: { + type: Boolean, + }, + fileSnapshots: { + type: [fileSnapshotSchema], + default: undefined, + }, }, { timestamps: true }, ); diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 3d5de455d1..0636ddbf47 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -1,6 +1,32 @@ import type { Types } from 'mongoose'; import type { IMessage } from './message'; +/** + * Immutable snapshot of a file referenced by a shared chat snapshot. Captured at + * share create/update so shared-link viewers can preview/download the file through + * the share-scoped routes without consulting the original owner's live file ACL. + * References the original stored object (no byte copy); only the metadata needed to + * stream/preview is duplicated. + */ +export interface SharedFileSnapshot { + file_id: string; + source?: string; + storageKey?: string; + filepath?: string; + type?: string; + filename?: string; + bytes?: number; + width?: number; + height?: number; + model?: string; + /** Deferred-preview generation marker captured at share time. The share routes + * refuse to serve when the live file's revision no longer matches (the file_id + * was reused/overwritten by a later turn), so a link can't surface post-share + * content. */ + previewRevision?: string; + tenantId?: string; +} + export interface ISharedLink { _id?: Types.ObjectId; conversationId: string; @@ -14,6 +40,15 @@ export interface ISharedLink { updatedAt?: Date; /** Owning tenant for multi-tenant deployments (read by the shared-link access middleware). */ tenantId?: string; + /** + * Per-link choice of whether the conversation's files are included in the share + * (the "share files" checkbox). `false` means the viewer sees no files; absent + * means a legacy link (treated as included). Distinct from `fileSnapshots` so an + * opt-out is never mistaken for a not-yet-backfilled legacy link. + */ + snapshotFiles?: boolean; + /** Per-share file snapshot referenced by the share-scoped file routes. */ + fileSnapshots?: SharedFileSnapshot[]; } export interface ShareServiceError extends Error { @@ -101,6 +136,7 @@ export interface GetShareLinkResult { _id?: string; shareId: string | null; targetMessageId?: string; + snapshotFiles?: boolean; success: boolean; }