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

* 🔗 feat: Snapshot Files for Shared-Link Attachments

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses the third Codex review pass on PR #13740:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -828,6 +828,10 @@ AZURE_CONTAINER_NAME=files
ALLOW_SHARED_LINKS=true
# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set.
ALLOW_SHARED_LINKS_PUBLIC=false
# Snapshot files referenced by a shared chat so viewers can preview/download them through
# the shared link (instead of the owner's file ACL). Enabled by default; overrides the
# `interface.sharedLinks.snapshotFiles` yaml setting when set.
# SHARED_LINKS_SNAPSHOT_FILES=true
#==============================#
# Static File Cache Control #

View file

@ -108,7 +108,9 @@ const fileAccess = async (req, res, next) => {
// Tenant-scoped files are restricted to their tenant. Legacy files without
// tenantId remain governed by owner/agent ACLs for non-tenant migrations.
if (fileTenantId && fileTenantId !== userTenantId) {
logger.warn(`[fileAccess] User ${userId} denied cross-tenant access to file ${fileId}`);
logger.warn(
`[fileAccess] User ${userId} denied cross-tenant access to file ${fileId} (route ${req.originalUrl})`,
);
return denyFileAccess(res);
}
@ -129,7 +131,9 @@ const fileAccess = async (req, res, next) => {
return next();
}
logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`);
logger.warn(
`[fileAccess] User ${userId} denied access to file ${fileId} (route ${req.originalUrl})`,
);
return denyFileAccess(res);
} catch (error) {
logger.error('[fileAccess] Error checking file access:', error);

View file

@ -0,0 +1,70 @@
const cookie = require('cookie');
const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { getUserById } = require('~/models');
const verifyRefreshToken = (token) => {
try {
const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
return typeof payload?.id === 'string' ? payload.id : null;
} catch {
return null;
}
};
/**
* Fallback auth for share file routes that are hit by `<img>`/anchor requests,
* which can't carry the bearer access token. Resolves the viewer from the
* `refreshToken` cookie (or the signed `openid_user_id` cookie) the same
* mechanism secure image links use so non-public shared links can authorize
* the viewer's ACL. Never blocks: on any failure it leaves `req.user` unset and
* lets `canAccessSharedLink` decide (public access, 401, or 403).
*/
const optionalShareFileAuth = async (req, res, next) => {
if (req.user) {
return next();
}
try {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) {
return next();
}
const parsed = cookie.parse(cookieHeader);
const useOpenId =
parsed.token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS);
const token = useOpenId ? parsed.openid_user_id : parsed.refreshToken;
if (!token) {
return next();
}
const userId = verifyRefreshToken(token);
if (!userId) {
return next();
}
// Resolve in system context: this runs before canAccessSharedLink establishes
// the share tenant, so under strict tenant isolation a tenant-scoped User
// query would otherwise throw. The viewer's id comes from their own verified
// refresh token; the share's tenant-scoped ACL check still gates access.
const user = await runAsSystem(() =>
getUserById(userId, '-password -__v -totpSecret -backupCodes'),
);
if (user) {
user.id = user._id.toString();
if (!user.role) {
user.role = SystemRoles.USER;
}
req.user = user;
}
} catch (error) {
logger.warn('[optionalShareFileAuth] cookie auth failed:', error?.message);
}
return next();
};
module.exports = optionalShareFileAuth;

View file

@ -0,0 +1,84 @@
const mockVerify = jest.fn();
const mockGetUserById = jest.fn();
jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }));
jest.mock('@librechat/data-schemas', () => ({
logger: { warn: jest.fn(), error: jest.fn() },
runAsSystem: (fn) => fn(),
}));
jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }));
jest.mock('~/models', () => ({ getUserById: (...args) => mockGetUserById(...args) }));
const optionalShareFileAuth = require('./optionalShareFileAuth');
const run = async (req) => {
const next = jest.fn();
await optionalShareFileAuth(req, {}, next);
return next;
};
describe('optionalShareFileAuth', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.JWT_REFRESH_SECRET = 'test-secret';
});
it('short-circuits when a bearer user is already set (no cookie work)', async () => {
const req = { user: { id: 'u1' }, headers: { cookie: 'refreshToken=x' } };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(mockVerify).not.toHaveBeenCalled();
expect(mockGetUserById).not.toHaveBeenCalled();
});
it('resolves the viewer from a valid refreshToken cookie', async () => {
mockVerify.mockReturnValue({ id: 'viewer-1' });
mockGetUserById.mockResolvedValue({ _id: 'viewer-1', role: 'USER' });
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(mockVerify).toHaveBeenCalledWith('good.jwt', 'test-secret');
expect(req.user).toMatchObject({ id: 'viewer-1', role: 'USER' });
});
it('defaults the role to USER when the record has none', async () => {
mockVerify.mockReturnValue({ id: 'viewer-2' });
mockGetUserById.mockResolvedValue({ _id: 'viewer-2' });
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
await run(req);
expect(req.user.role).toBe('USER');
});
it('leaves req.user unset when there is no cookie', async () => {
const req = { headers: {} };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
expect(mockGetUserById).not.toHaveBeenCalled();
});
it('leaves req.user unset when the token is invalid', async () => {
mockVerify.mockImplementation(() => {
throw new Error('bad token');
});
const req = { headers: { cookie: 'refreshToken=bad' } };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
expect(mockGetUserById).not.toHaveBeenCalled();
});
it('uses the signed openid_user_id cookie for OpenID-reuse sessions', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
mockVerify.mockReturnValue({ id: 'oidc-1' });
mockGetUserById.mockResolvedValue({ _id: 'oidc-1', role: 'USER' });
const req = {
headers: { cookie: 'token_provider=openid; openid_user_id=signed.jwt' },
};
await run(req);
expect(mockVerify).toHaveBeenCalledWith('signed.jwt', 'test-secret');
expect(req.user).toMatchObject({ id: 'oidc-1' });
delete process.env.OPENID_REUSE_TOKENS;
});
});

View file

@ -14,14 +14,18 @@ jest.mock('@librechat/api', () => ({
updateSharedLinkPermissionsExpiration: (...args) =>
mockUpdateSharedLinkPermissionsExpiration(...args),
ensureLinkPermissions: jest.fn(),
isFileSnapshotEnabled: jest.fn(() => true),
isFileSnapshotKillSwitchActive: jest.fn(() => false),
deleteSharedLinkWithCleanup: jest.fn(),
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn() },
logger: { error: jest.fn(), warn: jest.fn() },
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
runAsSystem: jest.fn((fn) => fn()),
tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
}));
jest.mock('librechat-data-provider', () => ({
@ -36,6 +40,14 @@ jest.mock('librechat-data-provider', () => ({
ALL: 'all',
TEMPORARY: 'temporary',
},
FileSources: {
local: 'local',
s3: 's3',
cloudfront: 'cloudfront',
azure_blob: 'azure_blob',
firebase: 'firebase',
text: 'text',
},
}));
jest.mock('mongoose', () => ({
@ -50,26 +62,50 @@ jest.mock('mongoose', () => ({
}));
jest.mock('~/models', () => ({
getFiles: jest.fn(),
updateFile: jest.fn(),
getSharedMessages: jest.fn(),
createSharedLink: jest.fn(),
updateSharedLink: jest.fn(),
deleteSharedLink: jest.fn(),
getSharedLinks: jest.fn(),
getSharedLink: jest.fn(),
getSharedLinkFile: jest.fn(),
backfillSharedLinkFiles: jest.fn(),
getRoleByName: jest.fn(),
}));
const mockGetStrategyFunctions = jest.fn();
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args),
}));
jest.mock('~/server/utils/files', () => ({
cleanFileName: jest.fn((name) => name),
getContentDisposition: jest.fn((name, disposition = 'attachment') => `${disposition}; ${name}`),
}));
jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next());
jest.mock('~/server/middleware/optionalShareFileAuth', () => (_req, _res, next) => next());
jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next());
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
jest.mock('~/server/middleware/config/app', () => (_req, _res, next) => next());
const { Readable } = require('stream');
const { RetentionMode } = require('librechat-data-provider');
const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas');
const { deleteSharedLinkWithCleanup } = require('@librechat/api');
const {
deleteSharedLinkWithCleanup,
isFileSnapshotEnabled,
isFileSnapshotKillSwitchActive,
} = require('@librechat/api');
const {
getFiles,
updateFile,
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinkFile,
backfillSharedLinkFiles,
getRoleByName,
} = require('~/models');
const shareRouter = require('../share');
@ -147,6 +183,7 @@ describe('share routes retention', () => {
'convo-123',
'msg-123',
new Date('2030-01-01T00:00:00.000Z'),
true,
);
expect(mockGrantCreationPermissions).toHaveBeenCalledWith(
'link-123',
@ -157,6 +194,72 @@ describe('share routes retention', () => {
expect(mockSharedLinksAccess).toHaveBeenCalled();
});
it('snapshots files by default when the user does not opt out', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
await request(buildApp()).post('/api/share/convo-123').send({ targetMessageId: 'msg-123' });
expect(createSharedLink).toHaveBeenCalledWith(
'user-123',
'convo-123',
'msg-123',
expect.anything(),
true,
);
});
it('does not snapshot files when the user opts out (snapshotFiles=false)', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
await request(buildApp())
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123', snapshotFiles: false });
expect(createSharedLink).toHaveBeenCalledWith(
'user-123',
'convo-123',
'msg-123',
expect.anything(),
false,
);
});
it('forces snapshotFiles=false when the feature is disabled, ignoring the body flag', async () => {
isFileSnapshotEnabled.mockReturnValueOnce(false);
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
await request(buildApp())
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123', snapshotFiles: true });
expect(createSharedLink).toHaveBeenCalledWith(
'user-123',
'convo-123',
'msg-123',
expect.anything(),
false,
);
});
it('passes the snapshotFiles opt-out through on update', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
await request(buildApp()).patch('/api/share/share-123').send({ snapshotFiles: false });
expect(updateSharedLink).toHaveBeenCalledWith(
'user-123',
'share-123',
undefined,
expect.anything(),
false,
);
});
it('rejects new shares when the retained conversation expired', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
@ -210,6 +313,7 @@ describe('share routes retention', () => {
'share-123',
undefined,
new Date('2030-01-01T00:00:00.000Z'),
true,
);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith(
'link-456',
@ -253,7 +357,7 @@ describe('share routes retention', () => {
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
});
@ -266,7 +370,13 @@ describe('share routes retention', () => {
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, undefined);
expect(updateSharedLink).toHaveBeenCalledWith(
'user-123',
'share-123',
undefined,
undefined,
true,
);
expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled();
});
@ -286,7 +396,7 @@ describe('share routes retention', () => {
'[getSharedLinkExpiration] Error creating expiration date:',
error,
);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
});
@ -305,6 +415,7 @@ describe('share routes retention', () => {
'share-123',
'msg-456',
new Date('2030-01-01T00:00:00.000Z'),
true,
);
});
@ -327,3 +438,233 @@ describe('share routes retention', () => {
expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123');
});
});
describe('share-scoped file routes', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetStrategyFunctions.mockReturnValue({
getDownloadStream: jest.fn(async () => Readable.from(['file-bytes'])),
});
// Live file record present by default (resolveShareFile requires it).
getFiles.mockResolvedValue([{ status: 'ready' }]);
});
it('serves a snapshotted image inline from its original stored object', async () => {
const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
getSharedLinkFile.mockResolvedValue({
file: {
file_id: 'file-1',
source: 'local',
filepath: '/images/owner/pic.png',
type: 'image/png',
filename: 'pic.png',
},
hasSnapshots: true,
});
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('image/png');
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['content-disposition']).toContain('inline');
expect(mockGetStrategyFunctions).toHaveBeenCalledWith('local');
expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png');
expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
});
it('forces attachment for unsafe inline types (no stored XSS)', async () => {
const getDownloadStream = jest.fn(async () => Readable.from(['<svg/>']));
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
getSharedLinkFile.mockResolvedValue({
file: {
file_id: 'file-1',
source: 'local',
filepath: '/uploads/owner/evil.svg',
type: 'image/svg+xml',
filename: 'evil.svg',
},
hasSnapshots: true,
});
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/octet-stream');
expect(response.headers['content-disposition']).toContain('attachment');
expect(response.headers['x-content-type-options']).toBe('nosniff');
});
it('downloads a snapshotted file as an attachment', async () => {
getSharedLinkFile.mockResolvedValue({
file: {
file_id: 'file-1',
source: 'local',
filepath: '/uploads/owner/file-1',
type: 'application/pdf',
filename: 'report.pdf',
},
hasSnapshots: true,
});
const response = await request(buildApp()).get('/api/share/share-123/files/file-1/download');
expect(response.status).toBe(200);
expect(response.headers['content-disposition']).toContain('attachment');
});
it('returns preview status read live from the file record', async () => {
getSharedLinkFile.mockResolvedValue({
file: { file_id: 'file-1', source: 'local' },
hasSnapshots: true,
});
getFiles.mockResolvedValue([{ status: 'ready', text: 'extracted text', textFormat: 'text' }]);
const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview');
expect(response.status).toBe(200);
expect(response.body).toEqual({
file_id: 'file-1',
status: 'ready',
text: 'extracted text',
textFormat: 'text',
});
expect(getFiles).toHaveBeenCalledWith({ file_id: 'file-1' }, null, {});
});
it('404s for a file not in the snapshot without rebuilding it', async () => {
getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: true });
const response = await request(buildApp()).get('/api/share/share-123/files/not-shared');
expect(response.status).toBe(404);
expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
});
it('lazily backfills only a legacy share that has no snapshot field', async () => {
getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false });
backfillSharedLinkFiles.mockResolvedValue({
file_id: 'file-1',
source: 'local',
filepath: '/images/owner/pic.png',
type: 'image/png',
filename: 'pic.png',
});
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(200);
expect(backfillSharedLinkFiles).toHaveBeenCalledWith('share-123', 'file-1');
});
it('404s cleanly when the snapshotted file is no longer available', async () => {
getSharedLinkFile.mockResolvedValue({
file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/gone.pdf' },
hasSnapshots: true,
});
getFiles.mockResolvedValue([]); // original record deleted/expired
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(404);
expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
});
it('404s (no serving) when the global kill switch is active', async () => {
isFileSnapshotKillSwitchActive.mockReturnValueOnce(true);
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(404);
expect(getSharedLinkFile).not.toHaveBeenCalled();
expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
});
it('404s (no serving, no backfill) for a link that opted out of file sharing', async () => {
getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false, optedOut: true });
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(404);
expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
});
it('404s when the snapshotted file version was overwritten (revision mismatch)', async () => {
getSharedLinkFile.mockResolvedValue({
file: {
file_id: 'file-1',
source: 'local',
filepath: '/uploads/owner/x',
previewRevision: 'r1',
},
hasSnapshots: true,
});
getFiles.mockResolvedValue([{ status: 'ready', previewRevision: 'r2' }]);
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(404);
expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
});
it('404s when the snapshotted file was overwritten (size/bytes mismatch)', async () => {
getSharedLinkFile.mockResolvedValue({
file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/x', bytes: 100 },
hasSnapshots: true,
});
getFiles.mockResolvedValue([{ status: 'ready', bytes: 200 }]);
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(404);
expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
});
it('strips a cache-busting query string before local streaming', async () => {
const getDownloadStream = jest.fn(async () => Readable.from(['bytes']));
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
getSharedLinkFile.mockResolvedValue({
file: {
file_id: 'file-1',
source: 'local',
filepath: '/images/owner/pic.png?v=2',
type: 'image/png',
filename: 'pic.png',
bytes: 100,
},
hasSnapshots: true,
});
getFiles.mockResolvedValue([{ status: 'ready', bytes: 100 }]);
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
expect(response.status).toBe(200);
expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png');
});
it('sweeps an orphaned pending preview to failed', async () => {
getSharedLinkFile.mockResolvedValue({
file: { file_id: 'file-1', source: 'local' },
hasSnapshots: true,
});
const stale = new Date(Date.now() - 5 * 60 * 1000);
getFiles.mockResolvedValue([{ status: 'pending', updatedAt: stale }]);
updateFile.mockResolvedValue({ status: 'failed', previewError: 'orphaned' });
const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview');
expect(response.status).toBe(200);
expect(response.body).toEqual({
file_id: 'file-1',
status: 'failed',
previewError: 'orphaned',
});
expect(updateFile).toHaveBeenCalledWith(
{ file_id: 'file-1', status: 'failed', previewError: 'orphaned' },
{ status: 'pending', updatedAt: stale },
);
});
});

View file

@ -6,6 +6,7 @@ const {
resolveBuildInfo,
resolveTitleTiming,
sanitizeModelSpecs,
isFileSnapshotEnabled,
} = require('@librechat/api');
const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
@ -257,6 +258,7 @@ router.get('/', async function (req, res) {
...preLoginPayload,
...publicSharePayload,
...buildPostLoginPayload(),
sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig),
socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
interface: appConfig?.interfaceConfig,
titleGenerationTiming: resolveTitleTiming({

View file

@ -5,24 +5,39 @@ const {
generateCheckAccess,
grantCreationPermissions,
ensureLinkPermissions,
isFileSnapshotEnabled,
isFileSnapshotKillSwitchActive,
deleteSharedLinkWithCleanup,
updateSharedLinkPermissionsExpiration,
isActiveExpirationDate,
getSharedLinkExpiration,
} = require('@librechat/api');
const { logger, createTempChatExpirationDate } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
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({
@ -53,21 +68,265 @@ const resolveSharedLinkExpiration = (req, conversationId) =>
const allowSharedLinks =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
if (allowSharedLinks) {
router.get('/:shareId', optionalJwtAuth, canAccessSharedLink, async (req, res) => {
/** 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 share = await getSharedMessages(req.params.shareId, req.shareResourceId);
if (share) {
res.set('Cache-Control', 'private, no-store');
res.status(200).json(share);
} else {
res.status(404).end();
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.error('Error getting shared messages:', error);
res.status(500).json({ message: 'Error getting shared messages' });
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');
}
}
},
);
}
/**
@ -121,6 +380,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
success: share.success,
shareId: share.shareId,
targetMessageId: share.targetMessageId,
snapshotFiles: share.snapshotFiles,
conversationId: req.params.conversationId,
});
} catch (error) {
@ -129,37 +389,47 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
}
});
router.post('/:conversationId', requireJwtAuth, 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();
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' });
}
},
);
const role = await getRoleByName(req.user.role);
const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {};
const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true;
const created = await createSharedLink(
req.user.id,
req.params.conversationId,
targetMessageId,
expiredAt,
);
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, async (req, res) => {
router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => {
try {
const { targetMessageId } = req.body ?? {};
if (targetMessageId !== undefined && typeof targetMessageId !== 'string') {
@ -184,6 +454,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => {
req.params.shareId,
targetMessageId,
expiredAt,
isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
);
if (updatedShare) {
if (updatedShare._id && expiredAt !== undefined) {

View file

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

View file

@ -3,9 +3,10 @@ import copy from 'copy-to-clipboard';
import { useRecoilValue } from 'recoil';
import { Download } from 'lucide-react';
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useFileDownload, useSharedFileDownload } from '~/data-provider';
import { logger, sortPagesByRelevance, triggerDownload } from '~/utils';
import { useFileDownload } from '~/data-provider';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useShareContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -14,6 +15,7 @@ interface FilePreviewDialogProps {
onOpenChange: (open: boolean) => void;
fileName: string;
fileId?: string;
filePath?: string;
relevance?: number;
pages?: number[];
pageRelevance?: Record<number, number>;
@ -128,6 +130,7 @@ export default function FilePreviewDialog({
onOpenChange,
fileName,
fileId,
filePath,
relevance,
pages,
pageRelevance,
@ -136,7 +139,13 @@ export default function FilePreviewDialog({
}: FilePreviewDialogProps) {
const localize = useLocalize();
const user = useRecoilValue(store.user);
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', fileId, { direct: false });
const { shareId } = useShareContext();
const { refetch: downloadOwned } = useFileDownload(user?.id ?? '', fileId, { direct: false });
const { refetch: downloadShared } = useSharedFileDownload(shareId, fileId);
// Use the share route only for snapshotted files (filepath rewritten to the
// share path); otherwise fall back to the owner route.
const useShared = !!shareId && (filePath?.startsWith('/api/share/') ?? false);
const downloadFile = useShared ? downloadShared : downloadOwned;
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(null);

View file

@ -46,6 +46,7 @@ const Files = ({ message }: { message?: TMessage }) => {
onOpenChange={handleClose}
fileName={selectedFile?.filename ?? ''}
fileId={selectedFile?.file_id}
filePath={selectedFile?.filepath}
fileType={selectedFile?.type ?? undefined}
fileSize={(selectedFile as TFile)?.bytes}
/>

View file

@ -51,16 +51,17 @@ const Image = ({
const absoluteImageUrl = useMemo(() => {
if (!imagePath) return imagePath;
if (
imagePath.startsWith('http') ||
imagePath.startsWith('data:') ||
!imagePath.startsWith('/images/')
) {
if (imagePath.startsWith('http') || imagePath.startsWith('data:')) {
return imagePath;
}
const baseURL = apiBaseUrl();
return `${baseURL}${imagePath}`;
// Root-relative server paths (`/images/...` static, `/api/share/...` share
// routes) are resolved against the API base so they load under a subpath.
if (imagePath.startsWith('/images/') || imagePath.startsWith('/api/')) {
return `${apiBaseUrl()}${imagePath}`;
}
return imagePath;
}, [imagePath]);
const downloadImage = async () => {

View file

@ -1,8 +1,9 @@
import React from 'react';
import { FileSources } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import { FileSources, sharedFileDownload } from 'librechat-data-provider';
import { useCodeOutputDownload, useFileDownload } from '~/data-provider';
import { isHttpDownloadTarget, triggerDownload } from '~/utils';
import { useShareContext } from '~/Providers';
interface LogLinkProps {
href: string;
@ -47,6 +48,7 @@ export const useAttachmentLink = ({
source,
}: AttachmentLinkOptions) => {
const { showToast } = useToastContext();
const { shareId } = useShareContext();
const useLocalDownload = isLocallyStoredSource(source) && !!file_id && !!user;
const { refetch: downloadFromApi } = useFileDownload(user, file_id, { source });
@ -55,6 +57,15 @@ export const useAttachmentLink = ({
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
event.preventDefault();
try {
// In a shared view, a snapshotted file's href is rewritten to the share
// route; download it through the share-scoped path (authorized by share
// permission, not owner ACL). Non-snapshotted files fall through so the
// original href / code-output path still works when snapshots are disabled.
if (shareId && file_id && href.startsWith('/api/share/')) {
triggerDownload(sharedFileDownload(shareId, file_id), filename);
return;
}
if (!useLocalDownload && isHttpDownloadTarget(href)) {
triggerDownload(href, filename);
return;

View file

@ -1,12 +1,13 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { FileSources } from 'librechat-data-provider';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import LogLink from '../LogLink';
const mockShowToast = jest.fn();
const mockDownloadFromApi = jest.fn();
const mockDownloadFromUrl = jest.fn();
const mockTriggerDownload = jest.fn();
let mockShareContext: { shareId?: string } = {};
jest.mock('@librechat/client', () => ({
useToastContext: () => ({ showToast: mockShowToast }),
@ -17,6 +18,10 @@ jest.mock('~/data-provider', () => ({
useCodeOutputDownload: () => ({ refetch: mockDownloadFromUrl }),
}));
jest.mock('~/Providers', () => ({
useShareContext: () => mockShareContext,
}));
jest.mock('~/utils', () => ({
isHttpDownloadTarget: (target?: string | null) => /^https?:\/\//i.test(target ?? ''),
triggerDownload: (...args: Parameters<typeof mockTriggerDownload>) =>
@ -26,6 +31,7 @@ jest.mock('~/utils', () => ({
describe('LogLink download routing', () => {
beforeEach(() => {
jest.clearAllMocks();
mockShareContext = {};
});
it('navigates directly to http URLs when no stored file metadata is available', async () => {
@ -76,6 +82,28 @@ describe('LogLink download routing', () => {
expect(mockDownloadFromUrl).not.toHaveBeenCalled();
});
it('routes downloads through the share-scoped route in a shared view', async () => {
mockShareContext = { shareId: 'share-9' };
const filename = 'file.pdf';
render(
<LogLink href="/api/share/share-9/files/file-1" file_id="file-1" filename={filename}>
{filename}
</LogLink>,
);
fireEvent.click(screen.getByRole('link', { name: filename }));
await waitFor(() => {
expect(mockTriggerDownload).toHaveBeenCalledWith(
'/api/share/share-9/files/file-1/download',
'file.pdf',
);
});
expect(mockDownloadFromApi).not.toHaveBeenCalled();
expect(mockDownloadFromUrl).not.toHaveBeenCalled();
});
it('keeps legacy code-output handles on the blob download path', async () => {
const filename = 'legacy.txt';
mockDownloadFromUrl.mockResolvedValue({ data: 'blob:https://app.example.com/file' });

View file

@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, CopyCheck } from 'lucide-react';
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { OGDialogTemplate, Button, Spinner, OGDialog, Checkbox, Label } from '@librechat/client';
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import SharedLinkButton from './SharedLinkButton';
import { buildShareLinkUrl, cn } from '~/utils';
@ -22,8 +23,11 @@ export default function ShareButton({
children?: React.ReactNode;
}) {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const canSnapshotFiles = startupConfig?.sharedLinksSnapshotFilesEnabled === true;
const [showQR, setShowQR] = useState(false);
const [sharedLink, setSharedLink] = useState('');
const [snapshotFiles, setSnapshotFiles] = useState(true);
const [isCopying, setIsCopying] = useState(false);
const [announcement, setAnnouncement] = useState('');
const copyLink = useCopyToClipboard({ text: sharedLink });
@ -44,6 +48,14 @@ export default function ShareButton({
}
}, [shareId]);
// Reflect an existing link's stored "share files" choice so the checkbox isn't
// misleading (legacy links have no stored choice → keep the default of enabled).
useEffect(() => {
if (share?.success === true && typeof share.snapshotFiles === 'boolean') {
setSnapshotFiles(share.snapshotFiles);
}
}, [share?.success, share?.snapshotFiles]);
const button =
isLoading === true ? null : (
<SharedLinkButton
@ -53,6 +65,7 @@ export default function ShareButton({
showQR={showQR}
setShowQR={setShowQR}
setSharedLink={setSharedLink}
snapshotFiles={canSnapshotFiles ? snapshotFiles : undefined}
/>
);
@ -78,6 +91,33 @@ export default function ShareButton({
: localize('com_ui_share_create_message');
})()}
</div>
{canSnapshotFiles && isLoading !== true && (
<div className="flex items-start gap-3 px-2 py-2">
<Checkbox
id="share-files-checkbox"
checked={snapshotFiles}
onCheckedChange={(checked) => setSnapshotFiles(checked === true)}
aria-label={localize('com_ui_share_files')}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<Label
htmlFor="share-files-checkbox"
className="cursor-pointer text-sm font-medium text-text-primary"
>
{localize('com_ui_share_files')}
</Label>
<span className="text-xs text-text-secondary">
{localize('com_ui_share_files_description')}
</span>
{shareId && (
<span className="text-xs font-medium text-text-secondary">
{localize('com_ui_share_files_refresh_note')}
</span>
)}
</div>
</div>
)}
<div className="relative items-center overflow-auto rounded-lg p-2">
{showQR && (
<div className="mb-4 flex flex-col items-center">

View file

@ -37,6 +37,7 @@ export default function SharedLinkButton({
showQR,
setShowQR,
setSharedLink,
snapshotFiles,
}: {
share: TSharedLinkGetResponse | undefined;
conversationId: string;
@ -44,6 +45,7 @@ export default function SharedLinkButton({
showQR: boolean;
setShowQR: (showQR: boolean) => void;
setSharedLink: (sharedLink: string) => void;
snapshotFiles?: boolean;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
@ -99,7 +101,7 @@ export default function SharedLinkButton({
if (!shareId) {
return;
}
const updateShare = await mutateAsync({ shareId, targetMessageId });
const updateShare = await mutateAsync({ shareId, targetMessageId, snapshotFiles });
const newLink = generateShareLink(updateShare.shareId);
setSharedLink(newLink);
setAnnouncement(localize('com_ui_link_refreshed'));
@ -109,7 +111,7 @@ export default function SharedLinkButton({
};
const createShareLink = async () => {
const share = await mutate({ conversationId, targetMessageId });
const share = await mutate({ conversationId, targetMessageId, snapshotFiles });
const newLink = generateShareLink(share.shareId);
setSharedLink(newLink);
};

View file

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

View file

@ -118,6 +118,31 @@ export const useFileDownload = (
);
};
/**
* Blob download for a snapshotted file served through a shared link. Authorized
* by shared-link view permission (public/ACL) rather than the owner's file ACL.
* Idle by default; call `refetch` to download.
*/
export const useSharedFileDownload = (
shareId?: string,
file_id?: string,
): QueryObserverResult<string> => {
return useQuery(
[QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? ''],
async () => {
if (!shareId || !file_id) {
return;
}
const response = await dataService.getSharedFileDownload(shareId, file_id);
return window.URL.createObjectURL(response.data);
},
{
enabled: false,
retry: false,
},
);
};
export const useCodeOutputDownload = (url = ''): QueryObserverResult<string> => {
return useQuery(
[QueryKeys.fileDownload, url],
@ -157,6 +182,21 @@ export const fetchFilePreview = async (fileId: string): Promise<t.TFilePreview>
}
};
/** Preview fetch for a snapshotted file served through a shared link. */
export const fetchSharedFilePreview = async (
shareId: string,
fileId: string,
): Promise<t.TFilePreview> => {
try {
const data = await dataService.getSharedFilePreview(shareId, fileId);
consecutivePreviewErrors.delete(fileId);
return data;
} catch (err) {
consecutivePreviewErrors.set(fileId, (consecutivePreviewErrors.get(fileId) ?? 0) + 1);
throw err;
}
};
export const previewRefetchInterval = (
data: t.TFilePreview | undefined,
query: { queryKey: readonly unknown[] },
@ -194,10 +234,12 @@ export const _resetPreviewErrorCounter = (fileId?: string): void => {
export const useFilePreview = (
file_id: string | undefined,
config?: UseQueryOptions<t.TFilePreview, unknown, t.TFilePreview>,
shareId?: string,
): QueryObserverResult<t.TFilePreview, unknown> => {
return useQuery<t.TFilePreview, unknown, t.TFilePreview>(
[QueryKeys.filePreview, file_id],
() => fetchFilePreview(file_id ?? ''),
shareId ? [QueryKeys.filePreview, file_id, shareId] : [QueryKeys.filePreview, file_id],
() =>
shareId ? fetchSharedFilePreview(shareId, file_id ?? '') : fetchFilePreview(file_id ?? ''),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,

View file

@ -172,24 +172,32 @@ export const usePinConversationMutation = (
export const useCreateSharedLinkMutation = (
options?: t.MutationOptions<
t.TCreateShareLinkRequest,
{ conversationId: string; targetMessageId?: string }
{ conversationId: string; targetMessageId?: string; snapshotFiles?: boolean }
>,
): UseMutationResult<
t.TSharedLinkResponse,
unknown,
{ conversationId: string; targetMessageId?: string },
{ conversationId: string; targetMessageId?: string; snapshotFiles?: boolean },
unknown
> => {
const queryClient = useQueryClient();
const { onSuccess, ..._options } = options || {};
return useMutation(
({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => {
({
conversationId,
targetMessageId,
snapshotFiles,
}: {
conversationId: string;
targetMessageId?: string;
snapshotFiles?: boolean;
}) => {
if (!conversationId) {
throw new Error('Conversation ID is required');
}
return dataService.createSharedLink(conversationId, targetMessageId);
return dataService.createSharedLink(conversationId, targetMessageId, snapshotFiles);
},
{
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
@ -203,17 +211,25 @@ export const useCreateSharedLinkMutation = (
};
export const useUpdateSharedLinkMutation = (
options?: t.MutationOptions<t.TUpdateShareLinkRequest, t.TUpdateShareLinkRequest>,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TUpdateShareLinkRequest, unknown> => {
options?: t.MutationOptions<
t.TUpdateShareLinkRequest,
t.TUpdateShareLinkRequest & { snapshotFiles?: boolean }
>,
): UseMutationResult<
t.TSharedLinkResponse,
unknown,
t.TUpdateShareLinkRequest & { snapshotFiles?: boolean },
unknown
> => {
const queryClient = useQueryClient();
const { onSuccess, ..._options } = options || {};
return useMutation(
({ shareId, targetMessageId }) => {
({ shareId, targetMessageId, snapshotFiles }) => {
if (!shareId) {
throw new Error('Share ID is required');
}
return dataService.updateSharedLink(shareId, targetMessageId);
return dataService.updateSharedLink(shareId, targetMessageId, snapshotFiles);
},
{
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {

View file

@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider';
import { useFilePreview } from '~/data-provider';
import { useShareContext } from '~/Providers';
import store from '~/store';
interface UseAttachmentPreviewSyncResult {
@ -99,9 +100,10 @@ export default function useAttachmentPreviewSync(
const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready';
const messageId = (attachment as Partial<TAttachment> | undefined)?.messageId;
const { shareId } = useShareContext();
const enabled = !!fileId && baseStatus === 'pending';
const previewQuery = useFilePreview(fileId, { enabled });
const previewQuery = useFilePreview(fileId, { enabled }, shareId);
/* Effective status: prefer the polled record once it arrives, since
* the SSE handler may have already moved the cache forward and the

View file

@ -1595,6 +1595,9 @@
"com_ui_share_error": "There was an error sharing the chat link",
"com_ui_share_everyone": "Share with everyone",
"com_ui_share_everyone_description_var": "This {{resource}} will be available to everyone. Please make sure the {{resource}} is really meant to be shared with everyone. Be careful with your data.",
"com_ui_share_files": "Share files in this conversation",
"com_ui_share_files_description": "Images and files in this conversation won't be visible to viewers unless this is enabled.",
"com_ui_share_files_refresh_note": "Refresh the link to apply this change — files are snapshotted when the link is refreshed.",
"com_ui_share_link_to_chat": "Share link to chat",
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",

View file

@ -192,6 +192,7 @@ interface:
# create: false
# share: true
# public: true # Allows users to toggle "share with everyone" for their links. Whether anonymous access is permitted is controlled by ALLOW_SHARED_LINKS_PUBLIC.
# snapshotFiles: true # Snapshot files referenced by a shared chat so viewers can preview/download them via the link. Enabled by default; the SHARED_LINKS_SNAPSHOT_FILES env var overrides this.
# mcpServers:
# Controls user permissions for MCP (Model Context Protocol) server management
# - use: Allow users to use configured MCP servers

View file

@ -68,6 +68,7 @@ export * from './cache';
/* Shared Links */
export * from './shared-links/access';
export * from './shared-links/service';
export * from './shared-links/config';
/* Stream */
export * from './stream';
/* Diagnostics */

View file

@ -1,8 +1,8 @@
import { getTenantId, runAsSystem, tenantStorage } from '@librechat/data-schemas';
import { ResourceType, PermissionBits } from 'librechat-data-provider';
import { getTenantId, runAsSystem, tenantStorage } from '@librechat/data-schemas';
import type { Request, Response, NextFunction } from 'express';
import type { Types, Model } from 'mongoose';
import type { IUser } from '@librechat/data-schemas';
import type { Types, Model } from 'mongoose';
import { AccessControlService } from '~/acl/accessControlService';
import { autoMigrateLegacyLink } from './service';
import { isEnabled } from '~/utils';

View file

@ -0,0 +1,43 @@
import type { AppConfig } from '@librechat/data-schemas';
import { isFileSnapshotEnabled } from './config';
const withSharedLinks = (sharedLinks: unknown): AppConfig =>
({ interfaceConfig: { sharedLinks } }) as unknown as AppConfig;
describe('isFileSnapshotEnabled', () => {
const original = process.env.SHARED_LINKS_SNAPSHOT_FILES;
afterEach(() => {
if (original === undefined) {
delete process.env.SHARED_LINKS_SNAPSHOT_FILES;
} else {
process.env.SHARED_LINKS_SNAPSHOT_FILES = original;
}
});
it('defaults to enabled with no config or env', () => {
delete process.env.SHARED_LINKS_SNAPSHOT_FILES;
expect(isFileSnapshotEnabled()).toBe(true);
expect(isFileSnapshotEnabled({} as AppConfig)).toBe(true);
});
it('honors yaml snapshotFiles: false', () => {
delete process.env.SHARED_LINKS_SNAPSHOT_FILES;
expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: false }))).toBe(false);
});
it('defaults enabled when sharedLinks is a boolean', () => {
delete process.env.SHARED_LINKS_SNAPSHOT_FILES;
expect(isFileSnapshotEnabled(withSharedLinks(true))).toBe(true);
});
it('env override wins over yaml (env false beats yaml true)', () => {
process.env.SHARED_LINKS_SNAPSHOT_FILES = 'false';
expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: true }))).toBe(false);
});
it('env override wins over yaml (env true beats yaml false)', () => {
process.env.SHARED_LINKS_SNAPSHOT_FILES = 'true';
expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: false }))).toBe(true);
});
});

View file

@ -0,0 +1,33 @@
import type { AppConfig } from '@librechat/data-schemas';
import { isEnabled } from '~/utils';
/**
* Whether shared links should snapshot the files referenced by the shared chat
* snapshot. The `SHARED_LINKS_SNAPSHOT_FILES` env var overrides the yaml
* `interface.sharedLinks.snapshotFiles` value; both default to enabled.
*/
export function isFileSnapshotEnabled(appConfig?: AppConfig): boolean {
const envValue = process.env.SHARED_LINKS_SNAPSHOT_FILES;
if (envValue !== undefined) {
return isEnabled(envValue);
}
const sharedLinks = appConfig?.interfaceConfig?.sharedLinks;
if (sharedLinks && typeof sharedLinks === 'object') {
return sharedLinks.snapshotFiles !== false;
}
return true;
}
/**
* Viewer-independent global kill switch for serving shared-link files. Reading
* and serving must NOT depend on the viewer's resolved config (per-role/user
* overrides) only on the link's own stored choice plus this global env switch.
* Active only when `SHARED_LINKS_SNAPSHOT_FILES` is explicitly set to a disabled
* value; the creator's yaml choice is already captured per-link at share time.
*/
export function isFileSnapshotKillSwitchActive(): boolean {
const envValue = process.env.SHARED_LINKS_SNAPSHOT_FILES;
return envValue !== undefined && !isEnabled(envValue);
}

View file

@ -84,6 +84,13 @@ export const getSharedLinks = (
}${cursor ? `&cursor=${cursor}` : ''}`;
export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`;
export const updateSharedLink = (shareId: string) => `${shareRoot}/${shareId}`;
/** Share-scoped file routes: serve snapshotted files via shared-link permission. */
export const sharedFile = (shareId: string, fileId: string) =>
`${shareRoot}/${shareId}/files/${encodeURIComponent(fileId)}`;
export const sharedFileDownload = (shareId: string, fileId: string) =>
`${sharedFile(shareId, fileId)}/download`;
export const sharedFilePreview = (shareId: string, fileId: string) =>
`${sharedFile(shareId, fileId)}/preview`;
const keysEndpoint = `${BASE_URL}/api/keys`;

View file

@ -1248,6 +1248,7 @@ export const interfaceSchema = z
create: z.boolean().optional(),
share: z.boolean().optional(),
public: z.boolean().optional(),
snapshotFiles: z.boolean().optional(),
}),
])
.optional(),
@ -1311,6 +1312,7 @@ export const interfaceSchema = z
create: true,
share: true,
public: true,
snapshotFiles: true,
},
});
@ -1391,6 +1393,8 @@ export type TStartupConfig = {
modelDescriptions?: Record<string, Record<string, string>>;
sharedLinksEnabled: boolean;
publicSharedLinksEnabled: boolean;
/** Whether shared links snapshot conversation files (gates the per-link "share files" checkbox). */
sharedLinksSnapshotFilesEnabled?: boolean;
/** Effective default timing for when conversation titles become fetchable.
* `immediate` = fetch in parallel with the active stream (default);
* `final` = fetch only after the stream completes (legacy). */

View file

@ -79,15 +79,20 @@ export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetR
export function createSharedLink(
conversationId: string,
targetMessageId?: string,
snapshotFiles?: boolean,
): Promise<t.TSharedLinkResponse> {
return request.post(endpoints.createSharedLink(conversationId), { targetMessageId });
return request.post(endpoints.createSharedLink(conversationId), {
targetMessageId,
snapshotFiles,
});
}
export function updateSharedLink(
shareId: string,
targetMessageId?: string,
snapshotFiles?: boolean,
): Promise<t.TSharedLinkResponse> {
return request.patch(endpoints.updateSharedLink(shareId), { targetMessageId });
return request.patch(endpoints.updateSharedLink(shareId), { targetMessageId, snapshotFiles });
}
export function deleteSharedLink(shareId: string): Promise<m.TDeleteSharedLinkResponse> {
@ -437,6 +442,11 @@ export const getFilePreview = (fileId: string): Promise<f.TFilePreview> => {
return request.get(endpoints.filePreview(fileId));
};
/** Preview status for a snapshotted file served through a shared link. */
export const getSharedFilePreview = (shareId: string, fileId: string): Promise<f.TFilePreview> => {
return request.get(endpoints.sharedFilePreview(shareId, fileId));
};
export const getAgentFiles = (agentId: string): Promise<f.TFile[]> => {
return request.get(endpoints.agentFiles(agentId));
};
@ -723,6 +733,19 @@ export const getFileDownloadURL = async (
return request.get(`${endpoints.files()}/download-url/${userId}/${file_id}`);
};
/** Blob download for a snapshotted file served through a shared link. */
export const getSharedFileDownload = async (
shareId: string,
file_id: string,
): Promise<AxiosResponse> => {
return request.getResponse(endpoints.sharedFileDownload(shareId, file_id), {
responseType: 'blob',
headers: {
Accept: 'application/octet-stream',
},
});
};
export const getCodeOutputDownload = async (url: string): Promise<AxiosResponse> => {
return request.getResponse(url, {
responseType: 'blob',

View file

@ -36,7 +36,13 @@ export * from './accessPermissions';
export * from './keys';
/* api call helpers */
export * from './headers-helpers';
export { loginPage, registerPage, apiBaseUrl, buildLoginRedirectUrl } from './api-endpoints';
export {
loginPage,
registerPage,
apiBaseUrl,
sharedFileDownload,
buildLoginRedirectUrl,
} from './api-endpoints';
export { default as request } from './request';
export { dataService };
import * as dataService from './data-service';

View file

@ -377,6 +377,8 @@ export type TSharedLinkResponse = Pick<TSharedLink, 'shareId'> &
export type TSharedLinkGetResponse = Omit<TSharedLinkResponse, 'shareId'> & {
shareId: string | null;
success: boolean;
/** Per-link "share files" choice; absent on legacy links (treated as enabled). */
snapshotFiles?: boolean;
};
// type for getting conversation tags

View file

@ -2,9 +2,9 @@ import { nanoid } from 'nanoid';
import mongoose from 'mongoose';
import { Constants } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createShareMethods, type ShareMethods } from './share';
import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
import type * as t from '~/types';
import { createShareMethods, type ShareMethods } from './share';
describe('Share Methods', () => {
let mongoServer: MongoMemoryServer;
@ -12,6 +12,7 @@ describe('Share Methods', () => {
let SharedLink: mongoose.Model<t.ISharedLink>;
let Message: mongoose.Model<t.IMessage>;
let Conversation: SchemaWithMeiliMethods;
let File: mongoose.Model<t.IMongoFile>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
@ -28,6 +29,29 @@ describe('Share Methods', () => {
shareId: { type: String, index: true },
targetMessageId: { type: String, required: false, index: true },
expiredAt: { type: Date },
snapshotFiles: { type: Boolean },
fileSnapshots: { type: [mongoose.Schema.Types.Mixed], default: undefined },
},
{ timestamps: true },
);
const fileSchema = new mongoose.Schema(
{
user: { type: String, required: true },
file_id: { type: String, required: true, index: true },
filename: { type: String, required: true },
filepath: { type: String, required: true },
storageKey: String,
type: String,
bytes: Number,
source: String,
width: Number,
height: Number,
text: String,
textFormat: { type: String, enum: ['html', 'text'] },
status: { type: String, enum: ['pending', 'ready', 'failed'] },
previewError: String,
tenantId: String,
},
{ timestamps: true },
);
@ -75,6 +99,7 @@ describe('Share Methods', () => {
'Conversation',
conversationSchema,
)) as SchemaWithMeiliMethods;
File = mongoose.models.File || mongoose.model<t.IMongoFile>('File', fileSchema);
// Create share methods
shareMethods = createShareMethods(mongoose);
@ -89,6 +114,7 @@ describe('Share Methods', () => {
await SharedLink.deleteMany({});
await Message.deleteMany({});
await Conversation.deleteMany({});
await File.deleteMany({});
});
describe('createSharedLink', () => {
@ -486,10 +512,13 @@ describe('Share Methods', () => {
expect(shared?.manualSkills).toEqual(['research']);
expect(shared?.alwaysAppliedSkills).toEqual(['brand-voice']);
// User-uploaded files keep their render URL (filepath/preview) but drop storage internals.
// Render metadata (filename/type/dims) is kept, storage internals dropped. The
// file isn't snapshotted (no backing File record), so its original render URL is
// neutralized — viewers can only load files through the authorized share route.
const file = shared?.files?.[0];
expect(file).toMatchObject({ filename: 'upload.png', type: 'image/png' });
expect(file?.filepath).toBe('/images/upload.png');
expect(file).not.toHaveProperty('filepath');
expect(file).not.toHaveProperty('preview');
expect(file).not.toHaveProperty('storageKey');
expect(file).not.toHaveProperty('user');
expect(file).not.toHaveProperty('tenantId');
@ -498,15 +527,15 @@ describe('Share Methods', () => {
expect(file?.conversationId).toBe(shared?.conversationId);
expect(file?.conversationId).not.toBe(conversationId);
// Tool-call attachments keep their correlation id, payload, and render URL so
// citations still render, while storage-only fields are removed.
// Tool-call attachments keep their correlation id and payload so citations still
// render, while storage-only fields AND the original render URL are removed.
const attachment = shared?.attachments?.[0];
expect(attachment).toMatchObject({
toolCallId: 'call_abc',
type: 'web_search',
web_search: { results: [{ title: 'Cited source', link: 'https://example.com' }] },
filepath: '/images/result.json',
});
expect(attachment).not.toHaveProperty('filepath');
expect(attachment).not.toHaveProperty('storageKey');
expect(attachment).not.toHaveProperty('metadata');
});
@ -1507,4 +1536,440 @@ describe('Share Methods', () => {
expect(result?.messages[0].parentMessageId).toBe(Constants.NO_PARENT);
});
});
describe('file snapshots', () => {
const seedConversation = async (userId: string, conversationId: string) => {
await Conversation.create({ conversationId, title: 'Files Convo', user: userId });
};
const createFile = async (
userId: string,
overrides: Partial<t.IMongoFile> = {},
): Promise<string> => {
const file_id = `file_${nanoid()}`;
await File.create({
user: userId,
file_id,
filename: 'report.pdf',
filepath: `/uploads/${userId}/${file_id}`,
type: 'application/pdf',
bytes: 1024,
source: 'local',
...overrides,
});
return file_id;
};
test('createSharedLink captures snapshots from message files and attachments', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const imageId = await createFile(userId, {
type: 'image/png',
filename: 'pic.png',
filepath: `/images/${userId}/pic.png`,
width: 100,
height: 80,
});
const docId = await createFile(userId);
await Message.create([
{
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'with image',
isCreatedByUser: true,
files: [{ file_id: imageId, type: 'image/png', filepath: `/images/${userId}/pic.png` }],
},
{
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'with attachment',
isCreatedByUser: false,
attachments: [{ file_id: docId, type: 'application/pdf' }],
},
]);
const result = await shareMethods.createSharedLink(userId, conversationId);
const saved = await SharedLink.findOne({ shareId: result.shareId }).lean();
expect(saved?.fileSnapshots).toHaveLength(2);
const byId = new Map(saved?.fileSnapshots?.map((s) => [s.file_id, s]));
expect(byId.get(imageId)?.source).toBe('local');
expect(byId.get(imageId)?.storageKey).toBeUndefined();
expect(byId.get(docId)?.filename).toBe('report.pdf');
expect(byId.get(docId)?.filepath).toBe(`/uploads/${userId}/${docId}`);
});
test('createSharedLink with snapshotFiles=false stores no snapshots', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'hi',
isCreatedByUser: true,
files: [{ file_id: docId }],
});
const result = await shareMethods.createSharedLink(
userId,
conversationId,
undefined,
undefined,
false,
);
const saved = await SharedLink.findOne({ shareId: result.shareId }).lean();
expect(saved?.fileSnapshots).toBeUndefined();
});
test('snapshots skip non-streamable sources and missing file records', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const remoteId = await createFile(userId, { source: 'openai' });
const ghostId = `file_${nanoid()}`; // referenced but no File doc
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'hi',
isCreatedByUser: true,
files: [{ file_id: remoteId }, { file_id: ghostId }],
});
const result = await shareMethods.createSharedLink(userId, conversationId);
const saved = await SharedLink.findOne({ shareId: result.shareId }).lean();
expect(saved?.fileSnapshots ?? []).toHaveLength(0);
});
test('getSharedMessages rewrites snapshotted file URLs to the share route', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc',
isCreatedByUser: true,
files: [
{ file_id: docId, type: 'application/pdf', filepath: `/uploads/${userId}/${docId}` },
],
});
const { shareId } = await shareMethods.createSharedLink(userId, conversationId);
const result = await shareMethods.getSharedMessages(shareId);
const file = (result?.messages[0].files?.[0] ?? {}) as Record<string, unknown>;
expect(file.filepath).toBe(`/api/share/${shareId}/files/${docId}`);
// owner storage path must not leak
expect(String(file.filepath)).not.toContain(userId);
});
test('getSharedMessages neutralizes URLs for non-snapshotted files', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const remoteId = await createFile(userId, { source: 'openai' });
const originalPath = `/uploads/${userId}/${remoteId}`;
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc',
isCreatedByUser: true,
files: [{ file_id: remoteId, filepath: originalPath }],
});
const { shareId } = await shareMethods.createSharedLink(userId, conversationId);
const result = await shareMethods.getSharedMessages(shareId);
const file = (result?.messages[0].files?.[0] ?? {}) as Record<string, unknown>;
// Non-snapshotted (non-streamable source): original URL must not leak.
expect(file.filepath).toBeUndefined();
expect(file.preview).toBeUndefined();
});
test('updateSharedLink recomputes snapshots from current messages', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'no files yet',
isCreatedByUser: true,
});
const created = await shareMethods.createSharedLink(userId, conversationId);
let saved = await SharedLink.findOne({ shareId: created.shareId }).lean();
expect(saved?.fileSnapshots ?? []).toHaveLength(0);
const docId = await createFile(userId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'now with a file',
isCreatedByUser: false,
files: [{ file_id: docId }],
});
const updated = await shareMethods.updateSharedLink(userId, created.shareId);
saved = await SharedLink.findOne({ shareId: updated.shareId }).lean();
expect(saved?.fileSnapshots).toHaveLength(1);
expect(saved?.fileSnapshots?.[0].file_id).toBe(docId);
});
test('getSharedLinkFile returns the entry, null for unknown files', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc',
isCreatedByUser: true,
files: [{ file_id: docId }],
});
const { shareId } = await shareMethods.createSharedLink(userId, conversationId);
const found = await shareMethods.getSharedLinkFile(shareId, docId);
expect(found.file?.file_id).toBe(docId);
expect(found.hasSnapshots).toBe(true);
const missing = await shareMethods.getSharedLinkFile(shareId, 'file_does_not_exist');
expect(missing.file).toBeNull();
expect(missing.hasSnapshots).toBe(true);
});
test('backfillSharedLinkFiles populates a legacy share missing snapshots', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
const message = await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc',
isCreatedByUser: true,
files: [{ file_id: docId }],
});
const shareId = `share_${nanoid()}`;
// legacy share: no fileSnapshots
await SharedLink.create({
shareId,
conversationId,
user: userId,
messages: [message._id],
});
const before = await shareMethods.getSharedLinkFile(shareId, docId);
expect(before.file).toBeNull();
expect(before.hasSnapshots).toBe(false);
const backfilled = await shareMethods.backfillSharedLinkFiles(shareId, docId);
expect((backfilled as t.SharedFileSnapshot)?.file_id).toBe(docId);
const saved = await SharedLink.findOne({ shareId }).lean();
expect(saved?.fileSnapshots).toHaveLength(1);
});
test('does not snapshot a file owned by another user', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const otherUserId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
// File belongs to another user but is referenced in the sharer's message.
const victimId = await createFile(otherUserId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'borrowed file id',
isCreatedByUser: true,
files: [{ file_id: victimId }],
});
const result = await shareMethods.createSharedLink(userId, conversationId);
const saved = await SharedLink.findOne({ shareId: result.shareId }).lean();
expect(saved?.fileSnapshots ?? []).toHaveLength(0);
});
test('getSharedMessages strips files when the admin feature is disabled', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
const originalPath = `/uploads/${userId}/${docId}`;
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc',
isCreatedByUser: true,
files: [{ file_id: docId, filepath: originalPath }],
});
const { shareId } = await shareMethods.createSharedLink(userId, conversationId);
const result = await shareMethods.getSharedMessages(shareId, undefined, {
snapshotFiles: false,
});
// Files are stripped entirely, not just left unrewritten — no owner path leaks.
expect(result?.messages[0].files).toBeUndefined();
});
test('createSharedLink with snapshotFiles=false strips files and skips snapshots', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
const originalPath = `/uploads/${userId}/${docId}`;
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc with file',
isCreatedByUser: true,
files: [{ file_id: docId, type: 'image/png', filepath: originalPath }],
});
// User opts out of sharing files for this link.
const { shareId } = await shareMethods.createSharedLink(
userId,
conversationId,
undefined,
undefined,
false,
);
const saved = await SharedLink.findOne({ shareId }).lean();
expect(saved?.snapshotFiles).toBe(false);
expect(saved?.fileSnapshots).toBeUndefined();
// Even with the admin feature on, an opted-out link shows no files and is not
// backfilled on read (the prior bug: original paths leaked / snapshots re-created).
const result = await shareMethods.getSharedMessages(shareId, undefined, {
snapshotFiles: true,
});
expect(result?.messages[0].files).toBeUndefined();
const after = await SharedLink.findOne({ shareId }).lean();
expect(after?.fileSnapshots).toBeUndefined();
});
test('getSharedLink surfaces the per-link snapshotFiles choice', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'hi',
isCreatedByUser: true,
});
await shareMethods.createSharedLink(userId, conversationId, undefined, undefined, false);
const link = await shareMethods.getSharedLink(userId, conversationId);
expect(link.snapshotFiles).toBe(false);
});
test('getSharedMessages backfills and rewrites a legacy share on read', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
const message = await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc',
isCreatedByUser: true,
files: [{ file_id: docId, filepath: `/uploads/${userId}/${docId}` }],
});
const shareId = `share_${nanoid()}`;
await SharedLink.create({
shareId,
conversationId,
user: userId,
messages: [message._id],
});
const result = await shareMethods.getSharedMessages(shareId);
const file = (result?.messages[0].files?.[0] ?? {}) as Record<string, unknown>;
expect(file.filepath).toBe(`/api/share/${shareId}/files/${docId}`);
// snapshot persisted by the lazy backfill
const saved = await SharedLink.findOne({ shareId }).lean();
expect(saved?.fileSnapshots).toHaveLength(1);
});
test('does not snapshot transient text-source files', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const textId = await createFile(userId, { source: 'text' });
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'rag context',
isCreatedByUser: true,
files: [{ file_id: textId }],
});
const result = await shareMethods.createSharedLink(userId, conversationId);
const saved = await SharedLink.findOne({ shareId: result.shareId }).lean();
expect(saved?.fileSnapshots ?? []).toHaveLength(0);
});
test('updateSharedLink clears snapshots when snapshotFiles is disabled', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
await seedConversation(userId, conversationId);
const docId = await createFile(userId);
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'doc',
isCreatedByUser: true,
files: [{ file_id: docId }],
});
const created = await shareMethods.createSharedLink(userId, conversationId);
let saved = await SharedLink.findOne({ shareId: created.shareId }).lean();
expect(saved?.fileSnapshots).toHaveLength(1);
const updated = await shareMethods.updateSharedLink(
userId,
created.shareId,
undefined,
undefined,
false,
);
saved = await SharedLink.findOne({ shareId: updated.shareId }).lean();
expect(saved?.fileSnapshots).toBeUndefined();
});
});
});

View file

@ -1,5 +1,5 @@
import { nanoid } from 'nanoid';
import { Constants } from 'librechat-data-provider';
import { Constants, FileSources } from 'librechat-data-provider';
import type { FilterQuery, Model } from 'mongoose';
import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
import type * as t from '~/types';
@ -98,6 +98,122 @@ function sanitizeSharedFiles(files: unknown): t.SharedFile[] | undefined {
return sanitized.length > 0 ? sanitized : undefined;
}
/**
* Sources backed by a durable stored object that the share-scoped routes can
* stream with only `storageKey`/`filepath` + the request. Sources requiring
* owner-specific credentials (openai/azure assistants, execute_code, vectordb,
* OCR/parser pipelines) are skipped those files degrade to a 404 in the share
* view. `FileSources.text` is intentionally excluded: its `filepath` is a Multer
* temp path that the upload route deletes, so there is nothing durable to stream.
*/
const SNAPSHOT_STREAMABLE_SOURCES = new Set<string>([
FileSources.local,
FileSources.s3,
FileSources.cloudfront,
FileSources.azure_blob,
FileSources.firebase,
]);
/** Collect `file_id`s from a message's `files`/`attachments` array into `target`. */
function collectFileIds(items: unknown, target: Set<string>): void {
if (!Array.isArray(items)) {
return;
}
for (const item of items) {
if (item && typeof item === 'object') {
const fileId = (item as { file_id?: unknown }).file_id;
if (typeof fileId === 'string' && fileId) {
target.add(fileId);
}
}
}
}
/**
* Build the per-share file snapshot from the messages being shared. Captures only
* the metadata the share-scoped routes need to stream each file; references the
* original stored object (no byte copy). The lookup is scoped to the sharing
* user's own files so a message referencing another user's `file_id` can never
* widen access to it. Preview text/status is intentionally NOT embedded it is
* read live from the file record so snapshots stay small and never go stale.
*/
async function buildFileSnapshots(
mongoose: typeof import('mongoose'),
messages: t.IMessage[],
ownerId?: string,
): Promise<t.SharedFileSnapshot[]> {
if (!ownerId) {
return [];
}
const fileIds = new Set<string>();
for (const message of messages) {
collectFileIds(message.files, fileIds);
collectFileIds(message.attachments, fileIds);
}
if (fileIds.size === 0) {
return [];
}
const File = mongoose.models.File as Model<t.IMongoFile>;
const files = await File.find({ file_id: { $in: Array.from(fileIds) }, user: ownerId }).lean();
const snapshots: t.SharedFileSnapshot[] = [];
for (const file of files) {
const source = file.source ?? FileSources.local;
if (!SNAPSHOT_STREAMABLE_SOURCES.has(source)) {
continue;
}
snapshots.push({
file_id: file.file_id,
source,
storageKey: file.storageKey,
filepath: file.filepath,
type: file.type,
filename: file.filename,
bytes: file.bytes,
width: file.width,
height: file.height,
model: file.model,
previewRevision: file.previewRevision,
tenantId: file.tenantId,
});
}
return snapshots;
}
/** Share-scoped file route that serves a snapshotted file independent of owner ACL. */
function shareFileRoute(shareId: string, fileId: string): string {
return `/api/share/${shareId}/files/${encodeURIComponent(fileId)}`;
}
/**
* Point a snapshotted file's render URLs at the share-scoped route so viewers load
* it through the authorized share path (and the owner's storage path is not leaked).
*/
function applyShareFileRoute(
file: t.SharedFile,
shareId: string,
snapshotIds: Set<string>,
): t.SharedFile {
const fileId = file.file_id;
if (typeof fileId === 'string' && snapshotIds.has(fileId)) {
const route = shareFileRoute(shareId, fileId);
const next: t.SharedFile = { ...file, filepath: route };
if (file.preview !== undefined) {
next.preview = route;
}
return next;
}
// Not snapshotted (e.g. a non-streamable source on an included link): neutralize
// the render URLs so the owner's original path can't be loaded through the share.
const next: t.SharedFile = { ...file };
delete next.filepath;
delete next.preview;
return next;
}
/**
* Only surface a model name when it is an (already-anonymized) assistant id;
* otherwise omit it so the underlying provider/model is not disclosed.
@ -117,7 +233,13 @@ function anonymizeSharedModel(model?: string): string | undefined {
* field so render data (uploaded files, `toolCallId`, search results, generated
* outputs) is preserved without leaking storage internals.
*/
function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.SharedMessage[] {
function anonymizeMessages(
messages: t.IMessage[],
newConvoId: string,
shareId: string,
snapshotIds: Set<string>,
includeFiles: boolean,
): t.SharedMessage[] {
if (!Array.isArray(messages)) {
return [];
}
@ -127,18 +249,36 @@ function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.Shared
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const attachments = sanitizeSharedFiles(message.attachments)?.map((attachment) => ({
...attachment,
messageId: newMessageId,
conversationId: newConvoId,
}));
// When files are not shared for this link, omit files/attachments entirely so
// viewers can't load them through the owner's original (e.g. static) paths.
const attachments = includeFiles
? sanitizeSharedFiles(message.attachments)?.map((attachment) =>
applyShareFileRoute(
{
...attachment,
messageId: newMessageId,
conversationId: newConvoId,
},
shareId,
snapshotIds,
),
)
: undefined;
// Persisted file records can carry the original conversation/message ids;
// rewrite them to the anonymized ids so shared files don't expose them.
const files = sanitizeSharedFiles(message.files)?.map((file) => ({
...file,
...(file.conversationId !== undefined && { conversationId: newConvoId }),
...(file.messageId !== undefined && { messageId: newMessageId }),
}));
const files = includeFiles
? sanitizeSharedFiles(message.files)?.map((file) =>
applyShareFileRoute(
{
...file,
...(file.conversationId !== undefined && { conversationId: newConvoId }),
...(file.messageId !== undefined && { messageId: newMessageId }),
},
shareId,
snapshotIds,
),
)
: undefined;
const model = anonymizeSharedModel(message.model);
return {
@ -254,18 +394,29 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
conversationId: string,
targetMessageId?: string,
expiredAt?: Date,
snapshotFiles?: boolean,
) => Promise<t.CreateShareResult>;
updateSharedLink: (
user: string,
shareId: string,
targetMessageId?: string,
expiredAt?: Date | null,
snapshotFiles?: boolean,
) => Promise<t.UpdateShareResult>;
deleteSharedLink: (user: string, shareId: string) => Promise<t.DeleteShareResult | null>;
getSharedMessages: (
shareId: string,
shareObjectId?: string,
options?: { snapshotFiles?: boolean },
) => Promise<t.SharedMessagesResult | null>;
getSharedLinkFile: (
shareId: string,
fileId: string,
) => Promise<{ file: t.SharedFileSnapshot | null; hasSnapshots: boolean; optedOut: boolean }>;
backfillSharedLinkFiles: (
shareId: string,
fileId?: string,
) => Promise<t.SharedFileSnapshot | t.SharedFileSnapshot[] | null>;
deleteAllSharedLinks: (
user: string,
) => Promise<t.DeleteAllSharesResult & { deletedIds: string[] }>;
@ -280,6 +431,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
async function getSharedMessages(
shareId: string,
shareObjectId?: string,
options?: { snapshotFiles?: boolean },
): Promise<t.SharedMessagesResult | null> {
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
@ -292,7 +444,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.select('-__v')
.lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null;
if (!share?.conversationId) {
@ -306,13 +458,39 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
}
const newConvoId = anonymizeConvoId(share.conversationId);
const resolvedShareId = share.shareId || shareId;
/**
* Files are included only when the admin feature is enabled (options) AND the
* link's own choice wasn't opted out (`snapshotFiles === false`). When
* excluded, files/attachments are stripped from the payload so nothing leaks
* through the owner's original paths. Legacy links (no per-link choice and no
* snapshot yet) are backfilled here so their first view rewrites correctly.
*/
const adminEnabled = options?.snapshotFiles !== false;
const perLinkEnabled = share.snapshotFiles !== false;
const includeFiles = adminEnabled && perLinkEnabled;
let fileSnapshots = share.fileSnapshots;
if (includeFiles && fileSnapshots === undefined && share._id) {
fileSnapshots = await buildFileSnapshots(mongoose, messagesToShare, share.user);
await SharedLink.updateOne({ _id: share._id }, { $set: { fileSnapshots } });
}
const snapshotIds = includeFiles
? new Set<string>((fileSnapshots ?? []).map((snapshot) => snapshot.file_id))
: new Set<string>();
const result: t.SharedMessagesResult = {
shareId: share.shareId || shareId,
shareId: resolvedShareId,
title: share.title,
createdAt: share.createdAt,
updatedAt: share.updatedAt,
conversationId: newConvoId,
messages: anonymizeMessages(messagesToShare, newConvoId),
messages: anonymizeMessages(
messagesToShare,
newConvoId,
resolvedShareId,
snapshotIds,
includeFiles,
),
};
return result;
@ -480,6 +658,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
conversationId: string,
targetMessageId?: string,
expiredAt?: Date,
snapshotFiles: boolean = true,
): Promise<t.CreateShareResult> {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
@ -529,6 +708,17 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
const title = conversation.title || 'Untitled';
const messagesForSnapshot = conversationMessages as unknown as t.IMessage[];
const fileSnapshots = snapshotFiles
? await buildFileSnapshots(
mongoose,
targetMessageId
? getMessagesUpToTarget(messagesForSnapshot, targetMessageId)
: messagesForSnapshot,
user,
)
: [];
const shareId = nanoid();
const created = await SharedLink.create({
shareId,
@ -536,8 +726,10 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
messages: conversationMessages,
title,
user,
snapshotFiles,
...(targetMessageId && { targetMessageId }),
...(expiredAt && { expiredAt }),
...(snapshotFiles && { fileSnapshots }),
});
return { _id: created._id.toString(), shareId, conversationId, targetMessageId };
@ -573,11 +765,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
user,
...activeExpirationFilter<t.ISharedLink>(),
})
.select('shareId targetMessageId _id')
.select('shareId targetMessageId snapshotFiles _id')
.sort({ updatedAt: -1 })
.lean()) as {
shareId?: string;
targetMessageId?: string;
snapshotFiles?: boolean;
_id?: import('mongoose').Types.ObjectId;
} | null;
@ -589,6 +782,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
_id: share._id?.toString(),
shareId: share.shareId || null,
targetMessageId: share.targetMessageId,
snapshotFiles: share.snapshotFiles,
success: true,
};
} catch (error) {
@ -609,6 +803,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
shareId: string,
targetMessageId?: string,
expiredAt?: Date | null,
snapshotFiles: boolean = true,
): Promise<t.UpdateShareResult> {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
@ -632,15 +827,33 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
const newShareId = nanoid();
const hasNewExpiration = expiredAt instanceof Date;
const resolvedTargetMessageId = targetMessageId ?? share.targetMessageId;
const messagesForSnapshot = updatedMessages as unknown as t.IMessage[];
const fileSnapshots = snapshotFiles
? await buildFileSnapshots(
mongoose,
resolvedTargetMessageId
? getMessagesUpToTarget(messagesForSnapshot, resolvedTargetMessageId)
: messagesForSnapshot,
user,
)
: [];
// Clear any prior snapshot when snapshotting is off so a disabled-feature
// update can't keep serving stale file ids that the update dropped.
const unset = {
...(expiredAt === null ? { expiredAt: 1 } : {}),
...(snapshotFiles ? {} : { fileSnapshots: 1 }),
};
const update = {
$set: {
messages: updatedMessages,
user,
shareId: newShareId,
snapshotFiles,
...(resolvedTargetMessageId && { targetMessageId: resolvedTargetMessageId }),
...(hasNewExpiration && { expiredAt }),
...(snapshotFiles && { fileSnapshots }),
},
...(expiredAt === null ? { $unset: { expiredAt: 1 } } : {}),
...(Object.keys(unset).length > 0 ? { $unset: unset } : {}),
};
const updatedShare = (await SharedLink.findOneAndUpdate({ shareId, user }, update, {
@ -709,6 +922,79 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
}
}
/**
* Resolve a single file snapshot entry for a shared link, used by the
* share-scoped file routes to authorize a file without the owner's ACL.
* `hasSnapshots` distinguishes a legacy share (field absent caller may
* backfill) from an ordinary miss (field present but file not in it 404,
* no rebuild). `optedOut` is the per-link "share files" choice when true the
* route must 404 and never backfill, so an opted-out link can't expose files.
*/
async function getSharedLinkFile(
shareId: string,
fileId: string,
): Promise<{ file: t.SharedFileSnapshot | null; hasSnapshots: boolean; optedOut: boolean }> {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
const share = (await SharedLink.findOne({
shareId,
...activeExpirationFilter<t.ISharedLink>(),
})
.select('fileSnapshots snapshotFiles')
.lean()) as Pick<t.ISharedLink, 'fileSnapshots' | 'snapshotFiles'> | null;
if (!share) {
return { file: null, hasSnapshots: false, optedOut: false };
}
const hasSnapshots = share.fileSnapshots !== undefined;
const optedOut = share.snapshotFiles === false;
const file = share.fileSnapshots?.find((snapshot) => snapshot.file_id === fileId) ?? null;
return { file, hasSnapshots, optedOut };
}
/**
* Lazily build and persist the file snapshot for a legacy shared link that
* predates the feature. Mirrors the lazy migration done for legacy ACL grants.
* Returns the requested entry (or the full snapshot when no fileId is given).
*/
async function backfillSharedLinkFiles(
shareId: string,
fileId?: string,
): Promise<t.SharedFileSnapshot | t.SharedFileSnapshot[] | null> {
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
const share = (await SharedLink.findOne({
shareId,
...activeExpirationFilter<t.ISharedLink>(),
})
.populate({ path: 'messages', select: '-_id -__v -user' })
.lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null;
if (!share) {
return null;
}
let messages: t.IMessage[] = share.messages ?? [];
if (share.targetMessageId) {
messages = getMessagesUpToTarget(messages, share.targetMessageId);
}
const fileSnapshots = await buildFileSnapshots(mongoose, messages, share.user);
await SharedLink.updateOne({ shareId }, { $set: { fileSnapshots } });
if (fileId) {
return fileSnapshots.find((snapshot) => snapshot.file_id === fileId) ?? null;
}
return fileSnapshots;
} catch (error) {
logger.error('[backfillSharedLinkFiles] Error backfilling file snapshots', {
error: error instanceof Error ? error.message : 'Unknown error',
shareId,
});
return null;
}
}
// Return all methods
return {
getSharedLink,
@ -717,6 +1003,8 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
updateSharedLink,
deleteSharedLink,
getSharedMessages,
getSharedLinkFile,
backfillSharedLinkFiles,
deleteAllSharedLinks,
deleteConvoSharedLink,
};

View file

@ -1,4 +1,5 @@
import mongoose, { Schema, Document, Types } from 'mongoose';
import type { SharedFileSnapshot } from '~/types';
export interface ISharedLink extends Document {
conversationId: string;
@ -11,8 +12,34 @@ export interface ISharedLink extends Document {
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
snapshotFiles?: boolean;
fileSnapshots?: SharedFileSnapshot[];
}
/**
* Immutable file snapshot embedded on a shared link. Captures the metadata the
* share-scoped file routes need to stream/preview each referenced file without
* consulting the original owner's live file ACL. References the original stored
* object (no byte copy).
*/
const fileSnapshotSchema = new Schema<SharedFileSnapshot>(
{
file_id: { type: String, required: true },
source: { type: String },
storageKey: { type: String },
filepath: { type: String },
type: { type: String },
filename: { type: String },
bytes: { type: Number },
width: { type: Number },
height: { type: Number },
model: { type: String },
previewRevision: { type: String },
tenantId: { type: String },
},
{ _id: false },
);
const shareSchema: Schema<ISharedLink> = new Schema(
{
conversationId: {
@ -44,6 +71,13 @@ const shareSchema: Schema<ISharedLink> = new Schema(
expiredAt: {
type: Date,
},
snapshotFiles: {
type: Boolean,
},
fileSnapshots: {
type: [fileSnapshotSchema],
default: undefined,
},
},
{ timestamps: true },
);

View file

@ -1,6 +1,32 @@
import type { Types } from 'mongoose';
import type { IMessage } from './message';
/**
* Immutable snapshot of a file referenced by a shared chat snapshot. Captured at
* share create/update so shared-link viewers can preview/download the file through
* the share-scoped routes without consulting the original owner's live file ACL.
* References the original stored object (no byte copy); only the metadata needed to
* stream/preview is duplicated.
*/
export interface SharedFileSnapshot {
file_id: string;
source?: string;
storageKey?: string;
filepath?: string;
type?: string;
filename?: string;
bytes?: number;
width?: number;
height?: number;
model?: string;
/** Deferred-preview generation marker captured at share time. The share routes
* refuse to serve when the live file's revision no longer matches (the file_id
* was reused/overwritten by a later turn), so a link can't surface post-share
* content. */
previewRevision?: string;
tenantId?: string;
}
export interface ISharedLink {
_id?: Types.ObjectId;
conversationId: string;
@ -14,6 +40,15 @@ export interface ISharedLink {
updatedAt?: Date;
/** Owning tenant for multi-tenant deployments (read by the shared-link access middleware). */
tenantId?: string;
/**
* Per-link choice of whether the conversation's files are included in the share
* (the "share files" checkbox). `false` means the viewer sees no files; absent
* means a legacy link (treated as included). Distinct from `fileSnapshots` so an
* opt-out is never mistaken for a not-yet-backfilled legacy link.
*/
snapshotFiles?: boolean;
/** Per-share file snapshot referenced by the share-scoped file routes. */
fileSnapshots?: SharedFileSnapshot[];
}
export interface ShareServiceError extends Error {
@ -101,6 +136,7 @@ export interface GetShareLinkResult {
_id?: string;
shareId: string | null;
targetMessageId?: string;
snapshotFiles?: boolean;
success: boolean;
}