mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🔗 feat: Snapshot Files for Shared-Link Attachments (#13740)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🔗 feat: Snapshot Files for Shared-Link Attachments Shared-link viewers could read a shared conversation snapshot but not its attachments: file preview/download still went through the owner-scoped file ACL (the /api/files router sits behind requireJwtAuth + owner/agent checks), so anonymous viewers got 401s and authenticated non-owners got 403s — the repeated `[fileAccess] denied` warnings seen for the preview poller. Capture an immutable per-share file snapshot (embedded on the SharedLink document, referencing the original stored object — no byte copy) at share create/update, and serve those files through new share-scoped routes authorized by the existing shared-link view permission (public/ACL) plus snapshot membership, never the owner's live file ACL. - data-schemas: fileSnapshots on the share doc; capture in create/update; read-time rewrite of filepath/preview to /api/share/:id/files/:fileId; getSharedLinkFile + lazy backfillSharedLinkFiles for legacy links - api: GET /api/share/:shareId/files/:file_id[/download|/preview]; route context added to fileAccess denial logs - packages/api: isFileSnapshotEnabled resolver (env + yaml) - data-provider: interface.sharedLinks.snapshotFiles (default on) + client endpoints/services - client: ShareContext.shareId wired to Image, preview hook, and downloads - config: SHARED_LINKS_SNAPSHOT_FILES env override (default on) * 🔒 fix: Address Codex review on shared-link file snapshots Triage of the Codex review on PR #13740 (2 P1, 7 P2 — all valid): - P1 (cross-user access): scope the snapshot lookup to the sharing user's own files so a message referencing another user's file_id can't widen access. - P1 (stored XSS): the inline share-file route now serves only safe preview types inline (raster images/pdf); everything else is forced to attachment with X-Content-Type-Options: nosniff. - Stream shared downloads by default; redirect to a signed URL only on ?direct=true (blob/XHR callers work without bucket CORS). - Read preview status live from the file record (always current for deferred previews) and stop embedding extracted text in the share doc (16MB-limit risk). - Only lazily backfill when the fileSnapshots field is absent (legacy), not on every snapshot miss. - Backfill legacy shares before rewriting message URLs, and gate URL rewriting to public shares so non-public (ACL) shares keep prior behavior (img/anchor can't carry the bearer token). - Frontend: only route a download through the share path when the file was actually snapshotted (rewritten href / filepath), else fall back. * 🔑 feat: Authorize shared-link files for non-public shares via cookie Extends shared-link file access to non-public (ACL) shares (Codex finding 5). `<img>`/anchor requests can't carry the bearer access token, so non-public shares previously 401'd on file loads. Add an optional cookie-auth fallback on the share file routes that resolves the viewer from the `refreshToken` cookie (or signed `openid_user_id` cookie) — the same mechanism secure image links use (validateImageRequest) — then let canAccessSharedLink run the viewer's ACL check. - new middleware optionalShareFileAuth (+ unit spec); applied to the three share file routes after optionalJwtAuth - URL rewriting in getSharedMessages is no longer gated to public shares (the route now authorizes header-less requests), so files work uniformly across public and non-public shares; revert the now-unused req.sharePublic plumbing * 🔒 fix: Second Codex pass on shared-link file snapshots Addresses the follow-up Codex findings on PR #13740: - Don't snapshot transient text-source files: FileSources.text filepaths are Multer temp paths the upload route deletes, so they can't be streamed — removed from the streamable allowlist. - Unset stale snapshots on a disabled-feature update: updateSharedLink now $unsets fileSnapshots when snapshotFiles is false, so an opted-out update can't keep serving file ids the update dropped. - Load tenant config after share resolution: configMiddleware now runs after canAccessSharedLink (which enters the share's tenant ALS context), so per-tenant interface.sharedLinks.snapshotFiles overrides apply to anonymous public views. - Return a clean 404 when the snapshotted object is gone: resolveShareFile now requires the live file record and 404s if it's been deleted/expired, instead of letting the stream error after headers are sent (ENOENT / 500). (The re-flagged P1 about private-viewer rewriting was already fixed in the prior commit's cookie-auth change.) * 🔒 fix: Third Codex pass on shared-link file snapshots Addresses the third Codex review pass on PR #13740: - P1: keep shared previews/files pinned to the snapshotted version. Snapshot the small previewRevision; resolveShareFile 404s when the live file's revision no longer matches (file_id reused/overwritten by a later turn), so old links can't surface post-share content — covers both preview text and streamed bytes. - Honor the toggle as a kill switch: resolveShareFile 404s when snapshotFiles is disabled, instead of only skipping backfill, so disabling stops serving already-snapshotted file URLs. - Lazy-sweep orphaned 'pending' previews to 'failed' in the share preview route (mirrors the owner route) so the client poller reaches a terminal state. - Resolve the cookie-fallback user in runAsSystem so strict tenant isolation doesn't throw before canAccessSharedLink establishes the share tenant context. * ✨ feat: Per-link "share files" checkbox for shared links Add a checkbox to the share-link dialog (checked by default) letting the user choose whether to include the conversation's files in the shared link, with copy explaining images/files won't be visible to viewers otherwise. Opting out skips snapshot creation/serving for that link. - client: ShareButton renders the checkbox gated on the new startupConfig.sharedLinksSnapshotFilesEnabled flag; state threads through SharedLinkButton into the create/update mutations as `snapshotFiles`. - data-provider: createSharedLink/updateSharedLink send `snapshotFiles` in the body; TStartupConfig gains `sharedLinksSnapshotFilesEnabled`. - api: POST/PATCH /api/share compute snapshotFiles as isFileSnapshotEnabled(req.config) && body.snapshotFiles !== false (admin gate AND per-link opt-out); config.js exposes the effective enabled flag to clients. - en locale: com_ui_share_files (+ _description). * 🐛 fix: Make the "share files" opt-out actually hide files Unchecking "share files" at creation didn't hide anything: the shared message JSON still carried each file's original (e.g. static-served) path, and because opting out only meant "no fileSnapshots field" — indistinguishable from a legacy link — getSharedMessages would backfill snapshots on first view whenever the admin feature was on, re-enabling files entirely. Fix by persisting and honoring the per-link choice: - Store `snapshotFiles` (boolean) on the SharedLink so opt-out is distinct from a legacy link; set it on create and update. - getSharedMessages computes includeFiles = adminEnabled && link not opted out; when excluded it strips files/attachments from the payload (no original-path leak) and never backfills the opted-out link. - Surface the stored choice via getSharedLink so the dialog checkbox reflects an existing link's actual setting instead of always defaulting to checked. Note: changing the checkbox on an already-created link still applies only when the link is refreshed (which regenerates the URL) — a UX follow-up. * 🔒 fix: Close remaining shared-link file opt-out leaks (Codex) Follow-up to the per-link opt-out, addressing the third Codex pass: - Honor the opt-out on the file route too: getSharedLinkFile now returns the link's `optedOut` choice; resolveShareFile 404s (and never backfills) an opted-out link, so a direct /files/:id request can't re-create snapshots. - Make read/serve viewer-independent: the gate no longer uses the viewer's resolved config (isFileSnapshotEnabled(req.config)) — it uses the link's stored choice plus a global env-only kill switch (isFileSnapshotKillSwitchActive). A viewer's own interface.sharedLinks.snapshotFiles can no longer hide a link's files. Create/update still use the creator's config to set the per-link choice. - Neutralize render URLs for non-snapshotted files: applyShareFileRoute now strips filepath/preview for any file/attachment not in the snapshot, so the owner's original (e.g. static) path can't be loaded through the share. * 🔒 fix: Harden shared-file version pinning and local path handling (Codex) - Refuse reused/overwritten file snapshots more broadly: resolveShareFile now refuses to serve when either previewRevision OR `bytes` changed vs the snapshot. `bytes` catches non-office reused outputs (e.g. code-exec same-filename images that lack previewRevision) and is stable across S3 URL refresh and the pending->ready transition. Same-size content swaps remain a best-effort gap inherent to the no-byte-copy design. - Strip cache-busting query strings before local streaming: code-output images add `?v=...` to filepath; the share route now splits it off so getLocalFileStream resolves the real filename instead of a literal `*.png?v=...` path. * 💬 fix: Clarify that file-sharing changes apply on link refresh For an already-created shared link, changing the "share files" checkbox only takes effect when the link is refreshed (which regenerates the snapshot). Add a note under the checkbox, shown only when a link already exists, so the behavior isn't surprising: "Refresh the link to apply this change — files are snapshotted when the link is refreshed."
This commit is contained in:
parent
ef65f4a015
commit
e515063ffe
34 changed files with 1979 additions and 105 deletions
|
|
@ -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 #
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
70
api/server/middleware/optionalShareFileAuth.js
Normal file
70
api/server/middleware/optionalShareFileAuth.js
Normal 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;
|
||||
84
api/server/middleware/optionalShareFileAuth.spec.js
Normal file
84
api/server/middleware/optionalShareFileAuth.spec.js
Normal 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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
type TShareContext = { isSharedConvo?: boolean };
|
||||
type TShareContext = { isSharedConvo?: boolean; shareId?: string };
|
||||
|
||||
export const ShareContext = createContext<TShareContext>({} as TShareContext);
|
||||
export const useShareContext = () => useContext(ShareContext);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import copy from 'copy-to-clipboard';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { Download } from 'lucide-react';
|
||||
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import { useFileDownload, useSharedFileDownload } from '~/data-provider';
|
||||
import { logger, sortPagesByRelevance, triggerDownload } from '~/utils';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import { useShareContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ interface FilePreviewDialogProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
fileId?: string;
|
||||
filePath?: string;
|
||||
relevance?: number;
|
||||
pages?: number[];
|
||||
pageRelevance?: Record<number, number>;
|
||||
|
|
@ -128,6 +130,7 @@ export default function FilePreviewDialog({
|
|||
onOpenChange,
|
||||
fileName,
|
||||
fileId,
|
||||
filePath,
|
||||
relevance,
|
||||
pages,
|
||||
pageRelevance,
|
||||
|
|
@ -136,7 +139,13 @@ export default function FilePreviewDialog({
|
|||
}: FilePreviewDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const user = useRecoilValue(store.user);
|
||||
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', fileId, { direct: false });
|
||||
const { shareId } = useShareContext();
|
||||
const { refetch: downloadOwned } = useFileDownload(user?.id ?? '', fileId, { direct: false });
|
||||
const { refetch: downloadShared } = useSharedFileDownload(shareId, fileId);
|
||||
// Use the share route only for snapshotted files (filepath rewritten to the
|
||||
// share path); otherwise fall back to the owner route.
|
||||
const useShared = !!shareId && (filePath?.startsWith('/api/share/') ?? false);
|
||||
const downloadFile = useShared ? downloadShared : downloadOwned;
|
||||
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const Files = ({ message }: { message?: TMessage }) => {
|
|||
onOpenChange={handleClose}
|
||||
fileName={selectedFile?.filename ?? ''}
|
||||
fileId={selectedFile?.file_id}
|
||||
filePath={selectedFile?.filepath}
|
||||
fileType={selectedFile?.type ?? undefined}
|
||||
fileSize={(selectedFile as TFile)?.bytes}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -51,16 +51,17 @@ const Image = ({
|
|||
const absoluteImageUrl = useMemo(() => {
|
||||
if (!imagePath) return imagePath;
|
||||
|
||||
if (
|
||||
imagePath.startsWith('http') ||
|
||||
imagePath.startsWith('data:') ||
|
||||
!imagePath.startsWith('/images/')
|
||||
) {
|
||||
if (imagePath.startsWith('http') || imagePath.startsWith('data:')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
const baseURL = apiBaseUrl();
|
||||
return `${baseURL}${imagePath}`;
|
||||
// Root-relative server paths (`/images/...` static, `/api/share/...` share
|
||||
// routes) are resolved against the API base so they load under a subpath.
|
||||
if (imagePath.startsWith('/images/') || imagePath.startsWith('/api/')) {
|
||||
return `${apiBaseUrl()}${imagePath}`;
|
||||
}
|
||||
|
||||
return imagePath;
|
||||
}, [imagePath]);
|
||||
|
||||
const downloadImage = async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { FileSources, sharedFileDownload } from 'librechat-data-provider';
|
||||
import { useCodeOutputDownload, useFileDownload } from '~/data-provider';
|
||||
import { isHttpDownloadTarget, triggerDownload } from '~/utils';
|
||||
import { useShareContext } from '~/Providers';
|
||||
|
||||
interface LogLinkProps {
|
||||
href: string;
|
||||
|
|
@ -47,6 +48,7 @@ export const useAttachmentLink = ({
|
|||
source,
|
||||
}: AttachmentLinkOptions) => {
|
||||
const { showToast } = useToastContext();
|
||||
const { shareId } = useShareContext();
|
||||
|
||||
const useLocalDownload = isLocallyStoredSource(source) && !!file_id && !!user;
|
||||
const { refetch: downloadFromApi } = useFileDownload(user, file_id, { source });
|
||||
|
|
@ -55,6 +57,15 @@ export const useAttachmentLink = ({
|
|||
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
// In a shared view, a snapshotted file's href is rewritten to the share
|
||||
// route; download it through the share-scoped path (authorized by share
|
||||
// permission, not owner ACL). Non-snapshotted files fall through so the
|
||||
// original href / code-output path still works when snapshots are disabled.
|
||||
if (shareId && file_id && href.startsWith('/api/share/')) {
|
||||
triggerDownload(sharedFileDownload(shareId, file_id), filename);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!useLocalDownload && isHttpDownloadTarget(href)) {
|
||||
triggerDownload(href, filename);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import LogLink from '../LogLink';
|
||||
|
||||
const mockShowToast = jest.fn();
|
||||
const mockDownloadFromApi = jest.fn();
|
||||
const mockDownloadFromUrl = jest.fn();
|
||||
const mockTriggerDownload = jest.fn();
|
||||
let mockShareContext: { shareId?: string } = {};
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
|
|
@ -17,6 +18,10 @@ jest.mock('~/data-provider', () => ({
|
|||
useCodeOutputDownload: () => ({ refetch: mockDownloadFromUrl }),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useShareContext: () => mockShareContext,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
isHttpDownloadTarget: (target?: string | null) => /^https?:\/\//i.test(target ?? ''),
|
||||
triggerDownload: (...args: Parameters<typeof mockTriggerDownload>) =>
|
||||
|
|
@ -26,6 +31,7 @@ jest.mock('~/utils', () => ({
|
|||
describe('LogLink download routing', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockShareContext = {};
|
||||
});
|
||||
|
||||
it('navigates directly to http URLs when no stored file metadata is available', async () => {
|
||||
|
|
@ -76,6 +82,28 @@ describe('LogLink download routing', () => {
|
|||
expect(mockDownloadFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes downloads through the share-scoped route in a shared view', async () => {
|
||||
mockShareContext = { shareId: 'share-9' };
|
||||
const filename = 'file.pdf';
|
||||
|
||||
render(
|
||||
<LogLink href="/api/share/share-9/files/file-1" file_id="file-1" filename={filename}>
|
||||
{filename}
|
||||
</LogLink>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: filename }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTriggerDownload).toHaveBeenCalledWith(
|
||||
'/api/share/share-9/files/file-1/download',
|
||||
'file.pdf',
|
||||
);
|
||||
});
|
||||
expect(mockDownloadFromApi).not.toHaveBeenCalled();
|
||||
expect(mockDownloadFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps legacy code-output handles on the blob download path', async () => {
|
||||
const filename = 'legacy.txt';
|
||||
mockDownloadFromUrl.mockResolvedValue({ data: 'blob:https://app.example.com/file' });
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react';
|
|||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Copy, CopyCheck } from 'lucide-react';
|
||||
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
||||
import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { OGDialogTemplate, Button, Spinner, OGDialog, Checkbox, Label } from '@librechat/client';
|
||||
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import SharedLinkButton from './SharedLinkButton';
|
||||
import { buildShareLinkUrl, cn } from '~/utils';
|
||||
|
||||
|
|
@ -22,8 +23,11 @@ export default function ShareButton({
|
|||
children?: React.ReactNode;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const canSnapshotFiles = startupConfig?.sharedLinksSnapshotFilesEnabled === true;
|
||||
const [showQR, setShowQR] = useState(false);
|
||||
const [sharedLink, setSharedLink] = useState('');
|
||||
const [snapshotFiles, setSnapshotFiles] = useState(true);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const copyLink = useCopyToClipboard({ text: sharedLink });
|
||||
|
|
@ -44,6 +48,14 @@ export default function ShareButton({
|
|||
}
|
||||
}, [shareId]);
|
||||
|
||||
// Reflect an existing link's stored "share files" choice so the checkbox isn't
|
||||
// misleading (legacy links have no stored choice → keep the default of enabled).
|
||||
useEffect(() => {
|
||||
if (share?.success === true && typeof share.snapshotFiles === 'boolean') {
|
||||
setSnapshotFiles(share.snapshotFiles);
|
||||
}
|
||||
}, [share?.success, share?.snapshotFiles]);
|
||||
|
||||
const button =
|
||||
isLoading === true ? null : (
|
||||
<SharedLinkButton
|
||||
|
|
@ -53,6 +65,7 @@ export default function ShareButton({
|
|||
showQR={showQR}
|
||||
setShowQR={setShowQR}
|
||||
setSharedLink={setSharedLink}
|
||||
snapshotFiles={canSnapshotFiles ? snapshotFiles : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -78,6 +91,33 @@ export default function ShareButton({
|
|||
: localize('com_ui_share_create_message');
|
||||
})()}
|
||||
</div>
|
||||
{canSnapshotFiles && isLoading !== true && (
|
||||
<div className="flex items-start gap-3 px-2 py-2">
|
||||
<Checkbox
|
||||
id="share-files-checkbox"
|
||||
checked={snapshotFiles}
|
||||
onCheckedChange={(checked) => setSnapshotFiles(checked === true)}
|
||||
aria-label={localize('com_ui_share_files')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label
|
||||
htmlFor="share-files-checkbox"
|
||||
className="cursor-pointer text-sm font-medium text-text-primary"
|
||||
>
|
||||
{localize('com_ui_share_files')}
|
||||
</Label>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_ui_share_files_description')}
|
||||
</span>
|
||||
{shareId && (
|
||||
<span className="text-xs font-medium text-text-secondary">
|
||||
{localize('com_ui_share_files_refresh_note')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative items-center overflow-auto rounded-lg p-2">
|
||||
{showQR && (
|
||||
<div className="mb-4 flex flex-col items-center">
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export default function SharedLinkButton({
|
|||
showQR,
|
||||
setShowQR,
|
||||
setSharedLink,
|
||||
snapshotFiles,
|
||||
}: {
|
||||
share: TSharedLinkGetResponse | undefined;
|
||||
conversationId: string;
|
||||
|
|
@ -44,6 +45,7 @@ export default function SharedLinkButton({
|
|||
showQR: boolean;
|
||||
setShowQR: (showQR: boolean) => void;
|
||||
setSharedLink: (sharedLink: string) => void;
|
||||
snapshotFiles?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
|
@ -99,7 +101,7 @@ export default function SharedLinkButton({
|
|||
if (!shareId) {
|
||||
return;
|
||||
}
|
||||
const updateShare = await mutateAsync({ shareId, targetMessageId });
|
||||
const updateShare = await mutateAsync({ shareId, targetMessageId, snapshotFiles });
|
||||
const newLink = generateShareLink(updateShare.shareId);
|
||||
setSharedLink(newLink);
|
||||
setAnnouncement(localize('com_ui_link_refreshed'));
|
||||
|
|
@ -109,7 +111,7 @@ export default function SharedLinkButton({
|
|||
};
|
||||
|
||||
const createShareLink = async () => {
|
||||
const share = await mutate({ conversationId, targetMessageId });
|
||||
const share = await mutate({ conversationId, targetMessageId, snapshotFiles });
|
||||
const newLink = generateShareLink(share.shareId);
|
||||
setSharedLink(newLink);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ function SharedView() {
|
|||
);
|
||||
|
||||
return (
|
||||
<ShareContext.Provider value={{ isSharedConvo: true }}>
|
||||
<ShareContext.Provider value={{ isSharedConvo: true, shareId }}>
|
||||
<div className="relative flex h-screen w-full overflow-hidden dark:bg-surface-secondary">
|
||||
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
|
||||
{artifactsContainer}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,31 @@ export const useFileDownload = (
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Blob download for a snapshotted file served through a shared link. Authorized
|
||||
* by shared-link view permission (public/ACL) rather than the owner's file ACL.
|
||||
* Idle by default; call `refetch` to download.
|
||||
*/
|
||||
export const useSharedFileDownload = (
|
||||
shareId?: string,
|
||||
file_id?: string,
|
||||
): QueryObserverResult<string> => {
|
||||
return useQuery(
|
||||
[QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? ''],
|
||||
async () => {
|
||||
if (!shareId || !file_id) {
|
||||
return;
|
||||
}
|
||||
const response = await dataService.getSharedFileDownload(shareId, file_id);
|
||||
return window.URL.createObjectURL(response.data);
|
||||
},
|
||||
{
|
||||
enabled: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useCodeOutputDownload = (url = ''): QueryObserverResult<string> => {
|
||||
return useQuery(
|
||||
[QueryKeys.fileDownload, url],
|
||||
|
|
@ -157,6 +182,21 @@ export const fetchFilePreview = async (fileId: string): Promise<t.TFilePreview>
|
|||
}
|
||||
};
|
||||
|
||||
/** Preview fetch for a snapshotted file served through a shared link. */
|
||||
export const fetchSharedFilePreview = async (
|
||||
shareId: string,
|
||||
fileId: string,
|
||||
): Promise<t.TFilePreview> => {
|
||||
try {
|
||||
const data = await dataService.getSharedFilePreview(shareId, fileId);
|
||||
consecutivePreviewErrors.delete(fileId);
|
||||
return data;
|
||||
} catch (err) {
|
||||
consecutivePreviewErrors.set(fileId, (consecutivePreviewErrors.get(fileId) ?? 0) + 1);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const previewRefetchInterval = (
|
||||
data: t.TFilePreview | undefined,
|
||||
query: { queryKey: readonly unknown[] },
|
||||
|
|
@ -194,10 +234,12 @@ export const _resetPreviewErrorCounter = (fileId?: string): void => {
|
|||
export const useFilePreview = (
|
||||
file_id: string | undefined,
|
||||
config?: UseQueryOptions<t.TFilePreview, unknown, t.TFilePreview>,
|
||||
shareId?: string,
|
||||
): QueryObserverResult<t.TFilePreview, unknown> => {
|
||||
return useQuery<t.TFilePreview, unknown, t.TFilePreview>(
|
||||
[QueryKeys.filePreview, file_id],
|
||||
() => fetchFilePreview(file_id ?? ''),
|
||||
shareId ? [QueryKeys.filePreview, file_id, shareId] : [QueryKeys.filePreview, file_id],
|
||||
() =>
|
||||
shareId ? fetchSharedFilePreview(shareId, file_id ?? '') : fetchFilePreview(file_id ?? ''),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
|
|
|
|||
|
|
@ -172,24 +172,32 @@ export const usePinConversationMutation = (
|
|||
export const useCreateSharedLinkMutation = (
|
||||
options?: t.MutationOptions<
|
||||
t.TCreateShareLinkRequest,
|
||||
{ conversationId: string; targetMessageId?: string }
|
||||
{ conversationId: string; targetMessageId?: string; snapshotFiles?: boolean }
|
||||
>,
|
||||
): UseMutationResult<
|
||||
t.TSharedLinkResponse,
|
||||
unknown,
|
||||
{ conversationId: string; targetMessageId?: string },
|
||||
{ conversationId: string; targetMessageId?: string; snapshotFiles?: boolean },
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { onSuccess, ..._options } = options || {};
|
||||
return useMutation(
|
||||
({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => {
|
||||
({
|
||||
conversationId,
|
||||
targetMessageId,
|
||||
snapshotFiles,
|
||||
}: {
|
||||
conversationId: string;
|
||||
targetMessageId?: string;
|
||||
snapshotFiles?: boolean;
|
||||
}) => {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required');
|
||||
}
|
||||
|
||||
return dataService.createSharedLink(conversationId, targetMessageId);
|
||||
return dataService.createSharedLink(conversationId, targetMessageId, snapshotFiles);
|
||||
},
|
||||
{
|
||||
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
||||
|
|
@ -203,17 +211,25 @@ export const useCreateSharedLinkMutation = (
|
|||
};
|
||||
|
||||
export const useUpdateSharedLinkMutation = (
|
||||
options?: t.MutationOptions<t.TUpdateShareLinkRequest, t.TUpdateShareLinkRequest>,
|
||||
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TUpdateShareLinkRequest, unknown> => {
|
||||
options?: t.MutationOptions<
|
||||
t.TUpdateShareLinkRequest,
|
||||
t.TUpdateShareLinkRequest & { snapshotFiles?: boolean }
|
||||
>,
|
||||
): UseMutationResult<
|
||||
t.TSharedLinkResponse,
|
||||
unknown,
|
||||
t.TUpdateShareLinkRequest & { snapshotFiles?: boolean },
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { onSuccess, ..._options } = options || {};
|
||||
return useMutation(
|
||||
({ shareId, targetMessageId }) => {
|
||||
({ shareId, targetMessageId, snapshotFiles }) => {
|
||||
if (!shareId) {
|
||||
throw new Error('Share ID is required');
|
||||
}
|
||||
return dataService.updateSharedLink(shareId, targetMessageId);
|
||||
return dataService.updateSharedLink(shareId, targetMessageId, snapshotFiles);
|
||||
},
|
||||
{
|
||||
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
||||
import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider';
|
||||
import { useFilePreview } from '~/data-provider';
|
||||
import { useShareContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
||||
interface UseAttachmentPreviewSyncResult {
|
||||
|
|
@ -99,9 +100,10 @@ export default function useAttachmentPreviewSync(
|
|||
const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready';
|
||||
const messageId = (attachment as Partial<TAttachment> | undefined)?.messageId;
|
||||
|
||||
const { shareId } = useShareContext();
|
||||
const enabled = !!fileId && baseStatus === 'pending';
|
||||
|
||||
const previewQuery = useFilePreview(fileId, { enabled });
|
||||
const previewQuery = useFilePreview(fileId, { enabled }, shareId);
|
||||
|
||||
/* Effective status: prefer the polled record once it arrives, since
|
||||
* the SSE handler may have already moved the cache forward and the
|
||||
|
|
|
|||
|
|
@ -1595,6 +1595,9 @@
|
|||
"com_ui_share_error": "There was an error sharing the chat link",
|
||||
"com_ui_share_everyone": "Share with everyone",
|
||||
"com_ui_share_everyone_description_var": "This {{resource}} will be available to everyone. Please make sure the {{resource}} is really meant to be shared with everyone. Be careful with your data.",
|
||||
"com_ui_share_files": "Share files in this conversation",
|
||||
"com_ui_share_files_description": "Images and files in this conversation won't be visible to viewers unless this is enabled.",
|
||||
"com_ui_share_files_refresh_note": "Refresh the link to apply this change — files are snapshotted when the link is refreshed.",
|
||||
"com_ui_share_link_to_chat": "Share link to chat",
|
||||
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
|
||||
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
43
packages/api/src/shared-links/config.test.ts
Normal file
43
packages/api/src/shared-links/config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
33
packages/api/src/shared-links/config.ts
Normal file
33
packages/api/src/shared-links/config.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue