mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-04 05:13:52 +00:00
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."
261 lines
8.7 KiB
TypeScript
261 lines
8.7 KiB
TypeScript
import { useRecoilValue } from 'recoil';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { FileSources, QueryKeys, DynamicQueryKeys, dataService } from 'librechat-data-provider';
|
|
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
|
|
import type t from 'librechat-data-provider';
|
|
import { isEphemeralAgent } from '~/common';
|
|
import { addFileToCache } from '~/utils';
|
|
import store from '~/store';
|
|
|
|
export const useGetFiles = <TData = t.TFile[] | boolean>(
|
|
config?: UseQueryOptions<t.TFile[], unknown, TData>,
|
|
): QueryObserverResult<TData, unknown> => {
|
|
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
|
|
return useQuery<t.TFile[], unknown, TData>([QueryKeys.files], () => dataService.getFiles(), {
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
refetchOnMount: false,
|
|
...config,
|
|
enabled: (config?.enabled ?? true) === true && queriesEnabled,
|
|
});
|
|
};
|
|
|
|
export const useGetAgentFiles = <TData = t.TFile[]>(
|
|
agentId: string | undefined,
|
|
config?: UseQueryOptions<t.TFile[], unknown, TData>,
|
|
): QueryObserverResult<TData, unknown> => {
|
|
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
|
|
return useQuery<t.TFile[], unknown, TData>(
|
|
DynamicQueryKeys.agentFiles(agentId ?? ''),
|
|
() => (agentId ? dataService.getAgentFiles(agentId) : Promise.resolve([])),
|
|
{
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
refetchOnMount: false,
|
|
...config,
|
|
enabled: (config?.enabled ?? true) === true && queriesEnabled && !isEphemeralAgent(agentId),
|
|
},
|
|
);
|
|
};
|
|
|
|
export const useGetFileConfig = <TData = t.TFileConfig>(
|
|
config?: UseQueryOptions<t.TFileConfig, unknown, TData>,
|
|
): QueryObserverResult<TData, unknown> => {
|
|
return useQuery<t.TFileConfig, unknown, TData>(
|
|
[QueryKeys.fileConfig],
|
|
() => dataService.getFileConfig(),
|
|
{
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
refetchOnMount: false,
|
|
...config,
|
|
},
|
|
);
|
|
};
|
|
|
|
type FileDownloadOptions = {
|
|
source?: string | null;
|
|
direct?: boolean;
|
|
};
|
|
|
|
export const isDirectDownloadSource = (source?: string | null): boolean =>
|
|
source === FileSources.s3 || source === FileSources.cloudfront;
|
|
|
|
export const revokeDownloadURL = (url?: string | null): void => {
|
|
if (!url?.startsWith('blob:')) {
|
|
return;
|
|
}
|
|
window.URL.revokeObjectURL(url);
|
|
};
|
|
|
|
export const useFileDownload = (
|
|
userId?: string,
|
|
file_id?: string,
|
|
options: FileDownloadOptions = {},
|
|
): QueryObserverResult<string> => {
|
|
const queryClient = useQueryClient();
|
|
return useQuery(
|
|
[QueryKeys.fileDownload, file_id, options.source ?? '', options.direct ?? true],
|
|
async () => {
|
|
if (!userId || !file_id) {
|
|
console.warn('No user ID provided for file download');
|
|
return;
|
|
}
|
|
if ((options.direct ?? true) && isDirectDownloadSource(options.source)) {
|
|
try {
|
|
const directDownload = await dataService.getFileDownloadURL(userId, file_id);
|
|
if (directDownload.url) {
|
|
return directDownload.url;
|
|
}
|
|
} catch {
|
|
// Fall back to the legacy proxied download for direct URL failures.
|
|
}
|
|
}
|
|
|
|
const response = await dataService.getFileDownload(userId, file_id);
|
|
const blob = response.data;
|
|
const downloadURL = window.URL.createObjectURL(blob);
|
|
try {
|
|
const metadata: t.TFile | undefined = JSON.parse(
|
|
decodeURIComponent(response.headers['x-file-metadata']),
|
|
);
|
|
if (!metadata) {
|
|
console.warn('No metadata found for file download', response.headers);
|
|
return downloadURL;
|
|
}
|
|
|
|
addFileToCache(queryClient, metadata);
|
|
} catch (e) {
|
|
console.error('Error parsing file metadata, skipped updating file query cache', e);
|
|
}
|
|
|
|
return downloadURL;
|
|
},
|
|
{
|
|
enabled: false,
|
|
retry: false,
|
|
},
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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],
|
|
async () => {
|
|
if (!url) {
|
|
console.warn('No user ID provided for file download');
|
|
return;
|
|
}
|
|
const response = await dataService.getCodeOutputDownload(url);
|
|
const blob = response.data;
|
|
const downloadURL = window.URL.createObjectURL(blob);
|
|
return downloadURL;
|
|
},
|
|
{
|
|
enabled: false,
|
|
retry: false,
|
|
},
|
|
);
|
|
};
|
|
|
|
/* Stop on terminal success or after 5 consecutive errors. The cap is
|
|
* tracked in a module-level Map keyed by file_id because React Query
|
|
* v4 resets `state.fetchFailureCount` to 0 on every fetch dispatch
|
|
* (the `'fetch'` action in the reducer), so it can't be used to count
|
|
* errors *across* polls. */
|
|
export const PREVIEW_MAX_CONSECUTIVE_ERRORS = 5;
|
|
const consecutivePreviewErrors = new Map<string, number>();
|
|
|
|
export const fetchFilePreview = async (fileId: string): Promise<t.TFilePreview> => {
|
|
try {
|
|
const data = await dataService.getFilePreview(fileId);
|
|
consecutivePreviewErrors.delete(fileId);
|
|
return data;
|
|
} catch (err) {
|
|
consecutivePreviewErrors.set(fileId, (consecutivePreviewErrors.get(fileId) ?? 0) + 1);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
/** 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[] },
|
|
): number | false => {
|
|
const fileId = String(query.queryKey[1] ?? '');
|
|
if (data?.status === 'ready' || data?.status === 'failed') {
|
|
consecutivePreviewErrors.delete(fileId);
|
|
return false;
|
|
}
|
|
if ((consecutivePreviewErrors.get(fileId) ?? 0) >= PREVIEW_MAX_CONSECUTIVE_ERRORS) {
|
|
consecutivePreviewErrors.delete(fileId);
|
|
return false;
|
|
}
|
|
return 2500;
|
|
};
|
|
|
|
/** Test-only: clear the consecutive-error counter. */
|
|
export const _resetPreviewErrorCounter = (fileId?: string): void => {
|
|
if (fileId) consecutivePreviewErrors.delete(fileId);
|
|
else consecutivePreviewErrors.clear();
|
|
};
|
|
|
|
/**
|
|
* Poll the lifecycle of an inline file preview while background HTML
|
|
* extraction runs.
|
|
*
|
|
* Caller wires `enabled` to `attachment.status === 'pending'` so the
|
|
* query is dormant for terminal-status records. Once enabled, React
|
|
* Query's `refetchInterval` runs at 2.5s; see `previewRefetchInterval`
|
|
* for the auto-stop rules. Idle by default.
|
|
*
|
|
* Cache key: `[QueryKeys.filePreview, file_id]`. Sibling components
|
|
* watching the same `file_id` get a single shared poller.
|
|
*/
|
|
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>(
|
|
shareId ? [QueryKeys.filePreview, file_id, shareId] : [QueryKeys.filePreview, file_id],
|
|
() =>
|
|
shareId ? fetchSharedFilePreview(shareId, file_id ?? '') : fetchFilePreview(file_id ?? ''),
|
|
{
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
/* Note: `refetchOnMount` left at the React Query default (`true`)
|
|
* so a freshly-mounted observer with stale cached data refetches.
|
|
* Cross-turn filename reuse keeps the same `file_id`; the cache
|
|
* may hold a prior turn's `'ready'` payload. `useAttachmentHandler`
|
|
* removes the entry on every new attachment for safety, but this
|
|
* default is the second line of defense — without it, an observer
|
|
* that mounts before the handler runs would read the stale cache
|
|
* and `refetchInterval` would never start polling. (Codex P1
|
|
* round-3 review on PR #12957.) */
|
|
retry: false,
|
|
refetchInterval: previewRefetchInterval,
|
|
...config,
|
|
enabled: !!file_id && (config?.enabled ?? true),
|
|
},
|
|
);
|
|
};
|