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;
}