LibreChat/api/server/routes/share.js
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

488 lines
17 KiB
JavaScript

const mongoose = require('mongoose');
const express = require('express');
const {
isEnabled,
generateCheckAccess,
grantCreationPermissions,
ensureLinkPermissions,
isFileSnapshotEnabled,
isFileSnapshotKillSwitchActive,
deleteSharedLinkWithCleanup,
updateSharedLinkPermissionsExpiration,
isActiveExpirationDate,
getSharedLinkExpiration,
} = require('@librechat/api');
const {
logger,
runAsSystem,
tenantStorage,
createTempChatExpirationDate,
} = require('@librechat/data-schemas');
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getFiles,
updateFile,
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinks,
getSharedLink,
getSharedLinkFile,
backfillSharedLinkFiles,
getRoleByName,
} = require('~/models');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { cleanFileName, getContentDisposition } = require('~/server/utils/files');
const canAccessSharedLink = require('~/server/middleware/canAccessSharedLink');
const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth');
const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const configMiddleware = require('~/server/middleware/config/app');
const router = express.Router();
const checkSharedLinksAccess = generateCheckAccess({
permissionType: PermissionTypes.SHARED_LINKS,
permissions: [Permissions.CREATE],
getRoleByName,
});
const resolveSharedLinkExpiration = (req, conversationId) =>
getSharedLinkExpiration(
{ req, conversationId },
{
getConvo: async (userId, sourceConversationId) => {
const Conversation = mongoose.models.Conversation;
return Conversation.findOne(
{ conversationId: sourceConversationId, user: userId },
'isTemporary expiredAt',
).lean();
},
createExpirationDate: createTempChatExpirationDate,
logger,
},
);
/**
* Shared messages
*/
const allowSharedLinks =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
/** Run within the snapshot file's tenant context (mirrors canAccessSharedLink). */
const runWithTenant = (tenantId, fn) =>
tenantId ? tenantStorage.run({ tenantId }, fn) : runAsSystem(fn);
/** Mirrors the owner preview route: pending records older than this are swept to
* 'failed' on the next poll so the client poller terminates. */
const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000;
/**
* MIME types that are safe to render inline. Everything else (text/html, SVG,
* and other active content) is served as an `attachment` so a public viewer
* can't execute uploaded bytes under the app origin by opening the URL directly.
*/
const SAFE_INLINE_TYPES = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/bmp',
'image/avif',
'image/x-icon',
'application/pdf',
]);
/**
* Resolve a snapshotted file for a shared link. A file_id absent from the
* share's snapshot is denied (404) — this prevents a viewer from reaching files
* outside the shared-link snapshot. Only legacy shares (no `fileSnapshots` field
* at all) trigger a lazy backfill; an ordinary miss does not rebuild. The live
* file record is also required: if the original was deleted/expired, return a
* clean 404 instead of letting the stream error after headers are sent.
*/
const resolveShareFile = async (req, res, next) => {
try {
// Global kill switch only (env-based, viewer-independent): disabling stops
// serving for every link. The viewer's own config must NOT affect serving.
if (isFileSnapshotKillSwitchActive()) {
return res.status(404).json({ message: 'Shared file access is disabled' });
}
const { shareId, file_id } = req.params;
const { file, hasSnapshots, optedOut } = await getSharedLinkFile(shareId, file_id);
// Per-link opt-out: never serve and never backfill an opted-out link.
if (optedOut) {
return res.status(404).json({ message: 'File not found in shared link' });
}
let snapshot = file;
if (!snapshot && !hasSnapshots) {
snapshot = await backfillSharedLinkFiles(shareId, file_id);
}
if (!snapshot) {
logger.warn(
`[shareFileAccess] File ${file_id} not in snapshot for share ${shareId} (route ${req.originalUrl})`,
);
return res.status(404).json({ message: 'File not found in shared link' });
}
const [liveFile] = await getFiles({ file_id }, null, {});
if (!liveFile) {
logger.warn(
`[shareFileAccess] Snapshotted file ${file_id} no longer available for share ${shareId}`,
);
return res.status(404).json({ message: 'File no longer available' });
}
// Pin to the snapshotted version so an old link can't surface post-share content
// after a reused file_id (e.g. code-exec same-filename outputs) is overwritten.
// previewRevision changes for deferred/office files; `bytes` catches other
// overwrites that change size, and is stable across S3 URL refresh and the
// pending->ready transition (which don't alter file size). Same-size content
// swaps remain a best-effort gap inherent to the no-byte-copy design.
const revisionChanged =
(snapshot.previewRevision ?? null) !== (liveFile.previewRevision ?? null);
const bytesChanged =
snapshot.bytes != null && liveFile.bytes != null && snapshot.bytes !== liveFile.bytes;
if (revisionChanged || bytesChanged) {
logger.warn(
`[shareFileAccess] Snapshot version mismatch for file ${file_id} (share ${shareId})`,
);
return res.status(404).json({ message: 'File no longer available' });
}
req.shareFile = snapshot;
req.liveFile = liveFile;
return next();
} catch (error) {
logger.error('[shareFileAccess] Error resolving shared file:', error);
return res.status(500).json({ message: 'Error resolving shared file' });
}
};
/** Stream (or redirect to) a snapshotted file from its original stored object. */
const streamSharedFile = async (req, res, file, requestedDisposition) => {
const source = file.source || FileSources.local;
const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source);
// Inline only safe preview types; anything else is forced to attachment.
const disposition =
requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment';
// Redirect to a signed storage URL only when explicitly requested (?direct=true);
// by default stream through the server so blob (XHR) callers work without bucket CORS.
const isDirectSource = source === FileSources.s3 || source === FileSources.cloudfront;
if (req.query.direct === 'true' && getDownloadURL && isDirectSource) {
try {
const url = await getDownloadURL({
req,
file,
customFilename: cleanFileName(file.filename),
contentType: file.type || 'application/octet-stream',
});
if (url) {
res.setHeader('Cache-Control', 'no-store');
return res.redirect(302, url);
}
} catch (error) {
logger.warn('[shareFileAccess] download URL generation failed, streaming instead:', error);
}
}
if (!getDownloadStream) {
return res.status(501).send('Not Implemented');
}
// Strip any cache-busting query string (e.g. code-output images add `?v=...`) so
// the local stream resolves the real filename, not a literal `*.png?v=...` path.
const streamPath = (file.storageKey || file.filepath || '').split('?')[0];
const fileStream = await getDownloadStream(req, streamPath);
fileStream.on('error', (error) => {
logger.error('[shareFileAccess] Stream error:', error);
});
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', getContentDisposition(file.filename, disposition));
res.setHeader(
'Content-Type',
disposition === 'inline' ? file.type || 'application/octet-stream' : 'application/octet-stream',
);
res.setHeader('Cache-Control', 'private, max-age=3600');
return fileStream.pipe(res);
};
if (allowSharedLinks) {
router.get(
'/:shareId',
optionalJwtAuth,
canAccessSharedLink,
configMiddleware,
async (req, res) => {
try {
const share = await getSharedMessages(req.params.shareId, req.shareResourceId, {
// Viewer-independent: the per-link choice (stored on the share) decides
// file inclusion; only a global env kill switch can force it off here.
snapshotFiles: !isFileSnapshotKillSwitchActive(),
});
if (share) {
res.set('Cache-Control', 'private, no-store');
res.status(200).json(share);
} else {
res.status(404).end();
}
} catch (error) {
logger.error('Error getting shared messages:', error);
res.status(500).json({ message: 'Error getting shared messages' });
}
},
);
/**
* Preview status for a snapshotted file. Read live from the file record so the
* status is always current (deferred previews may resolve after the share was
* created) and large extracted text is never embedded in the share document.
*/
router.get(
'/:shareId/files/:file_id/preview',
optionalJwtAuth,
optionalShareFileAuth,
canAccessSharedLink,
configMiddleware,
resolveShareFile,
async (req, res) => {
try {
const { file_id } = req.params;
let liveFile = req.liveFile;
// Lazy-sweep orphaned pending records to 'failed' so the client preview
// poller reaches a terminal state (mirrors the owner preview route).
if (liveFile?.status === 'pending' && liveFile.updatedAt instanceof Date) {
const ageMs = Date.now() - liveFile.updatedAt.getTime();
if (ageMs > PREVIEW_LAZY_SWEEP_CUTOFF_MS) {
const swept = await updateFile(
{ file_id, status: 'failed', previewError: 'orphaned' },
{ status: 'pending', updatedAt: liveFile.updatedAt },
);
if (swept) {
liveFile = swept;
}
}
}
const status = liveFile?.status || 'ready';
const payload = { file_id, status };
if (status === 'ready' && liveFile?.text != null) {
payload.text = liveFile.text;
payload.textFormat = liveFile.textFormat ?? null;
} else if (status === 'failed' && liveFile?.previewError) {
payload.previewError = liveFile.previewError;
}
res.set('Cache-Control', 'private, no-store');
return res.status(200).json(payload);
} catch (error) {
logger.error('[shareFileAccess] Error fetching shared preview:', error);
return res.status(500).json({ message: 'Error fetching preview' });
}
},
);
/** Download a snapshotted file (attachment disposition). */
router.get(
'/:shareId/files/:file_id/download',
optionalJwtAuth,
optionalShareFileAuth,
canAccessSharedLink,
configMiddleware,
resolveShareFile,
async (req, res) => {
try {
await runWithTenant(req.shareFile.tenantId, () =>
streamSharedFile(req, res, req.shareFile, 'attachment'),
);
} catch (error) {
logger.error('[shareFileAccess] Error downloading shared file:', error);
if (!res.headersSent) {
res.status(500).send('Error downloading file');
}
}
},
);
/** Inline-serve a snapshotted file (image src, generic view). */
router.get(
'/:shareId/files/:file_id',
optionalJwtAuth,
optionalShareFileAuth,
canAccessSharedLink,
configMiddleware,
resolveShareFile,
async (req, res) => {
try {
await runWithTenant(req.shareFile.tenantId, () =>
streamSharedFile(req, res, req.shareFile, 'inline'),
);
} catch (error) {
logger.error('[shareFileAccess] Error serving shared file:', error);
if (!res.headersSent) {
res.status(500).send('Error serving file');
}
}
},
);
}
/**
* Shared links
*/
router.get('/', requireJwtAuth, async (req, res) => {
try {
const params = {
pageParam: req.query.cursor,
pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
: 'desc',
search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
};
const result = await getSharedLinks(
req.user.id,
params.pageParam,
params.pageSize,
params.sortBy,
params.sortDirection,
params.search,
);
res.status(200).send({
links: result.links,
nextCursor: result.nextCursor,
hasNextPage: result.hasNextPage,
});
} catch (error) {
logger.error('Error getting shared links:', error);
res.status(500).json({
message: 'Error getting shared links',
error: error.message,
});
}
});
router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
try {
const share = await getSharedLink(req.user.id, req.params.conversationId);
if (share._id && share.success) {
await ensureLinkPermissions(share._id, req.user.id);
}
return res.status(200).json({
_id: share._id,
success: share.success,
shareId: share.shareId,
targetMessageId: share.targetMessageId,
snapshotFiles: share.snapshotFiles,
conversationId: req.params.conversationId,
});
} catch (error) {
logger.error('Error getting shared link:', error);
res.status(500).json({ message: 'Error getting shared link' });
}
});
router.post(
'/:conversationId',
requireJwtAuth,
configMiddleware,
checkSharedLinksAccess,
async (req, res) => {
try {
const { targetMessageId } = req.body;
const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId);
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
return res.status(404).end();
}
const role = await getRoleByName(req.user.role);
const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {};
const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true;
// Per-link opt-out: snapshot only when the feature is enabled AND the user
// did not uncheck "share files" (body flag absent defaults to enabled).
const snapshotFiles = isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false;
const created = await createSharedLink(
req.user.id,
req.params.conversationId,
targetMessageId,
expiredAt,
snapshotFiles,
);
if (created) {
await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt);
res.status(200).json(created);
} else {
res.status(404).end();
}
} catch (error) {
logger.error('Error creating shared link:', error);
res.status(500).json({ message: 'Error creating shared link' });
}
},
);
router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => {
try {
const { targetMessageId } = req.body ?? {};
if (targetMessageId !== undefined && typeof targetMessageId !== 'string') {
return res.status(400).json({ message: 'targetMessageId must be a string' });
}
let expiredAt;
const SharedLink = mongoose.models.SharedLink;
const existing = await SharedLink.findOne(
{ shareId: req.params.shareId, user: req.user.id },
'conversationId',
).lean();
if (existing?.conversationId) {
expiredAt = await resolveSharedLinkExpiration(req, existing.conversationId);
}
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
return res.status(404).end();
}
const updatedShare = await updateSharedLink(
req.user.id,
req.params.shareId,
targetMessageId,
expiredAt,
isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
);
if (updatedShare) {
if (updatedShare._id && expiredAt !== undefined) {
await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
}
res.status(200).json(updatedShare);
} else {
res.status(404).end();
}
} catch (error) {
logger.error('Error updating shared link:', error);
res.status(500).json({ message: 'Error updating shared link' });
}
});
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
try {
const result = await deleteSharedLinkWithCleanup(req.user.id, req.params.shareId);
if (!result) {
return res.status(404).json({ message: 'Share not found' });
}
return res.status(200).json(result);
} catch (error) {
logger.error('Error deleting shared link:', error);
return res.status(400).json({ message: 'Error deleting shared link' });
}
});
module.exports = router;