LibreChat/client/src/data-provider/Files/queries.ts
Danny Avila e515063ffe
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 (#13740)
* 🔗 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."
2026-06-20 23:05:13 -04:00

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