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
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue