mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🔗 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
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:
parent
ef65f4a015
commit
e515063ffe
34 changed files with 1979 additions and 105 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue