🔗 feat: Snapshot Files for Shared-Link Attachments (#13740)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* 🔗 feat: Snapshot Files for Shared-Link Attachments

Shared-link viewers could read a shared conversation snapshot but not its
attachments: file preview/download still went through the owner-scoped file
ACL (the /api/files router sits behind requireJwtAuth + owner/agent checks),
so anonymous viewers got 401s and authenticated non-owners got 403s — the
repeated `[fileAccess] denied` warnings seen for the preview poller.

Capture an immutable per-share file snapshot (embedded on the SharedLink
document, referencing the original stored object — no byte copy) at share
create/update, and serve those files through new share-scoped routes
authorized by the existing shared-link view permission (public/ACL) plus
snapshot membership, never the owner's live file ACL.

- data-schemas: fileSnapshots on the share doc; capture in create/update;
  read-time rewrite of filepath/preview to /api/share/:id/files/:fileId;
  getSharedLinkFile + lazy backfillSharedLinkFiles for legacy links
- api: GET /api/share/:shareId/files/:file_id[/download|/preview]; route
  context added to fileAccess denial logs
- packages/api: isFileSnapshotEnabled resolver (env + yaml)
- data-provider: interface.sharedLinks.snapshotFiles (default on) + client
  endpoints/services
- client: ShareContext.shareId wired to Image, preview hook, and downloads
- config: SHARED_LINKS_SNAPSHOT_FILES env override (default on)

* 🔒 fix: Address Codex review on shared-link file snapshots

Triage of the Codex review on PR #13740 (2 P1, 7 P2 — all valid):

- P1 (cross-user access): scope the snapshot lookup to the sharing user's own
  files so a message referencing another user's file_id can't widen access.
- P1 (stored XSS): the inline share-file route now serves only safe preview
  types inline (raster images/pdf); everything else is forced to attachment with
  X-Content-Type-Options: nosniff.
- Stream shared downloads by default; redirect to a signed URL only on
  ?direct=true (blob/XHR callers work without bucket CORS).
- Read preview status live from the file record (always current for deferred
  previews) and stop embedding extracted text in the share doc (16MB-limit risk).
- Only lazily backfill when the fileSnapshots field is absent (legacy), not on
  every snapshot miss.
- Backfill legacy shares before rewriting message URLs, and gate URL rewriting
  to public shares so non-public (ACL) shares keep prior behavior (img/anchor
  can't carry the bearer token).
- Frontend: only route a download through the share path when the file was
  actually snapshotted (rewritten href / filepath), else fall back.

* 🔑 feat: Authorize shared-link files for non-public shares via cookie

Extends shared-link file access to non-public (ACL) shares (Codex finding 5).
`<img>`/anchor requests can't carry the bearer access token, so non-public
shares previously 401'd on file loads. Add an optional cookie-auth fallback on
the share file routes that resolves the viewer from the `refreshToken` cookie
(or signed `openid_user_id` cookie) — the same mechanism secure image links use
(validateImageRequest) — then let canAccessSharedLink run the viewer's ACL check.

- new middleware optionalShareFileAuth (+ unit spec); applied to the three
  share file routes after optionalJwtAuth
- URL rewriting in getSharedMessages is no longer gated to public shares (the
  route now authorizes header-less requests), so files work uniformly across
  public and non-public shares; revert the now-unused req.sharePublic plumbing

* 🔒 fix: Second Codex pass on shared-link file snapshots

Addresses the follow-up Codex findings on PR #13740:

- Don't snapshot transient text-source files: FileSources.text filepaths are
  Multer temp paths the upload route deletes, so they can't be streamed —
  removed from the streamable allowlist.
- Unset stale snapshots on a disabled-feature update: updateSharedLink now
  $unsets fileSnapshots when snapshotFiles is false, so an opted-out update
  can't keep serving file ids the update dropped.
- Load tenant config after share resolution: configMiddleware now runs after
  canAccessSharedLink (which enters the share's tenant ALS context), so
  per-tenant interface.sharedLinks.snapshotFiles overrides apply to anonymous
  public views.
- Return a clean 404 when the snapshotted object is gone: resolveShareFile now
  requires the live file record and 404s if it's been deleted/expired, instead
  of letting the stream error after headers are sent (ENOENT / 500).

(The re-flagged P1 about private-viewer rewriting was already fixed in the prior
commit's cookie-auth change.)

* 🔒 fix: Third Codex pass on shared-link file snapshots

Addresses the third Codex review pass on PR #13740:

- P1: keep shared previews/files pinned to the snapshotted version. Snapshot the
  small previewRevision; resolveShareFile 404s when the live file's revision no
  longer matches (file_id reused/overwritten by a later turn), so old links can't
  surface post-share content — covers both preview text and streamed bytes.
- Honor the toggle as a kill switch: resolveShareFile 404s when snapshotFiles is
  disabled, instead of only skipping backfill, so disabling stops serving
  already-snapshotted file URLs.
- Lazy-sweep orphaned 'pending' previews to 'failed' in the share preview route
  (mirrors the owner route) so the client poller reaches a terminal state.
- Resolve the cookie-fallback user in runAsSystem so strict tenant isolation
  doesn't throw before canAccessSharedLink establishes the share tenant context.

*  feat: Per-link "share files" checkbox for shared links

Add a checkbox to the share-link dialog (checked by default) letting the user
choose whether to include the conversation's files in the shared link, with
copy explaining images/files won't be visible to viewers otherwise. Opting out
skips snapshot creation/serving for that link.

- client: ShareButton renders the checkbox gated on the new
  startupConfig.sharedLinksSnapshotFilesEnabled flag; state threads through
  SharedLinkButton into the create/update mutations as `snapshotFiles`.
- data-provider: createSharedLink/updateSharedLink send `snapshotFiles` in the
  body; TStartupConfig gains `sharedLinksSnapshotFilesEnabled`.
- api: POST/PATCH /api/share compute snapshotFiles as
  isFileSnapshotEnabled(req.config) && body.snapshotFiles !== false (admin gate
  AND per-link opt-out); config.js exposes the effective enabled flag to clients.
- en locale: com_ui_share_files (+ _description).

* 🐛 fix: Make the "share files" opt-out actually hide files

Unchecking "share files" at creation didn't hide anything: the shared message
JSON still carried each file's original (e.g. static-served) path, and because
opting out only meant "no fileSnapshots field" — indistinguishable from a legacy
link — getSharedMessages would backfill snapshots on first view whenever the
admin feature was on, re-enabling files entirely.

Fix by persisting and honoring the per-link choice:
- Store `snapshotFiles` (boolean) on the SharedLink so opt-out is distinct from a
  legacy link; set it on create and update.
- getSharedMessages computes includeFiles = adminEnabled && link not opted out;
  when excluded it strips files/attachments from the payload (no original-path
  leak) and never backfills the opted-out link.
- Surface the stored choice via getSharedLink so the dialog checkbox reflects an
  existing link's actual setting instead of always defaulting to checked.

Note: changing the checkbox on an already-created link still applies only when
the link is refreshed (which regenerates the URL) — a UX follow-up.

* 🔒 fix: Close remaining shared-link file opt-out leaks (Codex)

Follow-up to the per-link opt-out, addressing the third Codex pass:

- Honor the opt-out on the file route too: getSharedLinkFile now returns the
  link's `optedOut` choice; resolveShareFile 404s (and never backfills) an
  opted-out link, so a direct /files/:id request can't re-create snapshots.
- Make read/serve viewer-independent: the gate no longer uses the viewer's
  resolved config (isFileSnapshotEnabled(req.config)) — it uses the link's stored
  choice plus a global env-only kill switch (isFileSnapshotKillSwitchActive). A
  viewer's own interface.sharedLinks.snapshotFiles can no longer hide a link's
  files. Create/update still use the creator's config to set the per-link choice.
- Neutralize render URLs for non-snapshotted files: applyShareFileRoute now
  strips filepath/preview for any file/attachment not in the snapshot, so the
  owner's original (e.g. static) path can't be loaded through the share.

* 🔒 fix: Harden shared-file version pinning and local path handling (Codex)

- Refuse reused/overwritten file snapshots more broadly: resolveShareFile now
  refuses to serve when either previewRevision OR `bytes` changed vs the
  snapshot. `bytes` catches non-office reused outputs (e.g. code-exec
  same-filename images that lack previewRevision) and is stable across S3 URL
  refresh and the pending->ready transition. Same-size content swaps remain a
  best-effort gap inherent to the no-byte-copy design.
- Strip cache-busting query strings before local streaming: code-output images
  add `?v=...` to filepath; the share route now splits it off so getLocalFileStream
  resolves the real filename instead of a literal `*.png?v=...` path.

* 💬 fix: Clarify that file-sharing changes apply on link refresh

For an already-created shared link, changing the "share files" checkbox only
takes effect when the link is refreshed (which regenerates the snapshot). Add a
note under the checkbox, shown only when a link already exists, so the behavior
isn't surprising: "Refresh the link to apply this change — files are snapshotted
when the link is refreshed."
This commit is contained in:
Danny Avila 2026-06-20 23:05:13 -04:00 committed by GitHub
parent ef65f4a015
commit e515063ffe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1979 additions and 105 deletions

View file

@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
type TShareContext = { isSharedConvo?: boolean };
type TShareContext = { isSharedConvo?: boolean; shareId?: string };
export const ShareContext = createContext<TShareContext>({} as TShareContext);
export const useShareContext = () => useContext(ShareContext);

View file

@ -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<number, number>;
@ -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<string | null>(null);
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(null);

View file

@ -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}
/>

View file

@ -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 () => {

View file

@ -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<HTMLAnchorElement | HTMLButtonElement>) => {
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;

View file

@ -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<typeof mockTriggerDownload>) =>
@ -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(
<LogLink href="/api/share/share-9/files/file-1" file_id="file-1" filename={filename}>
{filename}
</LogLink>,
);
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' });

View file

@ -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 : (
<SharedLinkButton
@ -53,6 +65,7 @@ export default function ShareButton({
showQR={showQR}
setShowQR={setShowQR}
setSharedLink={setSharedLink}
snapshotFiles={canSnapshotFiles ? snapshotFiles : undefined}
/>
);
@ -78,6 +91,33 @@ export default function ShareButton({
: localize('com_ui_share_create_message');
})()}
</div>
{canSnapshotFiles && isLoading !== true && (
<div className="flex items-start gap-3 px-2 py-2">
<Checkbox
id="share-files-checkbox"
checked={snapshotFiles}
onCheckedChange={(checked) => setSnapshotFiles(checked === true)}
aria-label={localize('com_ui_share_files')}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<Label
htmlFor="share-files-checkbox"
className="cursor-pointer text-sm font-medium text-text-primary"
>
{localize('com_ui_share_files')}
</Label>
<span className="text-xs text-text-secondary">
{localize('com_ui_share_files_description')}
</span>
{shareId && (
<span className="text-xs font-medium text-text-secondary">
{localize('com_ui_share_files_refresh_note')}
</span>
)}
</div>
</div>
)}
<div className="relative items-center overflow-auto rounded-lg p-2">
{showQR && (
<div className="mb-4 flex flex-col items-center">

View file

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

View file

@ -148,7 +148,7 @@ function SharedView() {
);
return (
<ShareContext.Provider value={{ isSharedConvo: true }}>
<ShareContext.Provider value={{ isSharedConvo: true, shareId }}>
<div className="relative flex h-screen w-full overflow-hidden dark:bg-surface-secondary">
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
{artifactsContainer}

View file

@ -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<string> => {
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<string> => {
return useQuery(
[QueryKeys.fileDownload, url],
@ -157,6 +182,21 @@ export const fetchFilePreview = async (fileId: string): Promise<t.TFilePreview>
}
};
/** Preview fetch for a snapshotted file served through a shared link. */
export const fetchSharedFilePreview = async (
shareId: string,
fileId: string,
): Promise<t.TFilePreview> => {
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<t.TFilePreview, unknown, t.TFilePreview>,
shareId?: string,
): QueryObserverResult<t.TFilePreview, unknown> => {
return useQuery<t.TFilePreview, unknown, t.TFilePreview>(
[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,

View file

@ -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<t.TUpdateShareLinkRequest, t.TUpdateShareLinkRequest>,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TUpdateShareLinkRequest, unknown> => {
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) => {

View file

@ -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<TAttachment> | 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

View file

@ -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.",