diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js
index 6c78287003..f18eef9c5f 100644
--- a/api/server/routes/__tests__/convos.spec.js
+++ b/api/server/routes/__tests__/convos.spec.js
@@ -445,6 +445,48 @@ describe('Convos Routes', () => {
});
});
+ describe('GET / search handling', () => {
+ const { getConvosByCursor } = require('~/models');
+
+ beforeEach(() => {
+ getConvosByCursor.mockResolvedValue({ conversations: [], nextCursor: null });
+ });
+
+ /** Express already percent-decodes `req.query`, so decoding a second time in the route
+ * threw URIError on any term containing a bare `%` and mangled `%xx`-looking text. */
+ it('accepts a search term containing a literal percent sign', async () => {
+ const response = await request(app)
+ .get('/api/convos')
+ .query({ isArchived: 'true', search: '100% ready' });
+
+ expect(response.status).toBe(200);
+ expect(getConvosByCursor).toHaveBeenCalledWith(
+ 'test-user-123',
+ expect.objectContaining({ search: '100% ready' }),
+ );
+ });
+
+ it('passes percent-escape-looking text through without decoding it', async () => {
+ const response = await request(app).get('/api/convos').query({ search: 'a%41b' });
+
+ expect(response.status).toBe(200);
+ expect(getConvosByCursor).toHaveBeenCalledWith(
+ 'test-user-123',
+ expect.objectContaining({ search: 'a%41b' }),
+ );
+ });
+
+ it('treats a whitespace-only search as no search', async () => {
+ const response = await request(app).get('/api/convos').query({ search: ' ' });
+
+ expect(response.status).toBe(200);
+ expect(getConvosByCursor).toHaveBeenCalledWith(
+ 'test-user-123',
+ expect.objectContaining({ search: undefined }),
+ );
+ });
+ });
+
describe('POST /archive', () => {
it('should archive a conversation successfully', async () => {
const mockConversationId = 'conv-123';
diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js
index 253fa93fd8..bd82f8c502 100644
--- a/api/server/routes/__tests__/share.spec.js
+++ b/api/server/routes/__tests__/share.spec.js
@@ -13,6 +13,8 @@ const mockCanAccessSharedLink = jest.fn((req, _res, next) => {
});
const mockGetAppConfig = jest.fn();
const mockGetTenantId = jest.fn(() => undefined);
+const mockParseSharedLinksPageSize = jest.fn(() => 10);
+const mockIsValidSharedLinksCursor = jest.fn(() => true);
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => true),
@@ -27,6 +29,13 @@ jest.mock('@librechat/api', () => ({
deleteSharedLinkWithCleanup: jest.fn(),
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
+ /* The list/query helpers are behaviour-tested in `@librechat/api`; the route only has
+ to be wired to them, so they stand in as doubles here. */
+ parseSharedLinksPageSize: (...args) => mockParseSharedLinksPageSize(...args),
+ isValidSharedLinksCursor: (...args) => mockIsValidSharedLinksCursor(...args),
+ buildShareFileEtag: (file) =>
+ `"share-${file.file_id}-${file.previewRevision ?? 0}-${file.bytes ?? 0}-${file.filepath ?? ''}"`,
+ MAX_SHARED_LINK_SEARCH_LENGTH: 256,
}));
jest.mock('@librechat/data-schemas', () => ({
@@ -133,6 +142,7 @@ const {
getSharedMessages,
createSharedLink,
updateSharedLink,
+ getSharedLinks,
getSharedLinkFile,
backfillSharedLinkFiles,
getRoleByName,
@@ -233,6 +243,59 @@ describe('share routes', () => {
expect(response.headers['cache-control']).toBe('private, no-store');
});
+ it('normalizes shared-link list parameters without double-decoding search text', async () => {
+ getSharedLinks.mockResolvedValue({ links: [], hasNextPage: false });
+ mockParseSharedLinksPageSize.mockReturnValueOnce(100);
+ const cursor = '2030-01-01T00:00:00.000Z';
+
+ const response = await request(buildApp()).get(
+ `/api/share?pageSize=1000&sortBy=createdAt&sortDirection=asc&search=100%25%20ready&cursor=${encodeURIComponent(cursor)}`,
+ );
+
+ expect(response.status).toBe(200);
+ expect(getSharedLinks).toHaveBeenCalledWith(
+ 'user-123',
+ cursor,
+ 100,
+ 'createdAt',
+ 'asc',
+ '100% ready',
+ );
+ });
+
+ it('rejects a cursor the validator refuses before querying', async () => {
+ mockIsValidSharedLinksCursor.mockReturnValueOnce(false);
+
+ const response = await request(buildApp()).get('/api/share?cursor=not-a-date');
+
+ expect(response.status).toBe(400);
+ expect(mockIsValidSharedLinksCursor).toHaveBeenCalledWith('not-a-date', 'createdAt');
+ expect(getSharedLinks).not.toHaveBeenCalled();
+ });
+
+ it('accepts the composite cursor issued for a titleless page', async () => {
+ getSharedLinks.mockResolvedValue({ links: [], hasNextPage: false });
+ const cursor = Buffer.from(
+ JSON.stringify({ primary: null, id: '0123456789abcdef01234567' }),
+ ).toString('base64');
+
+ const response = await request(buildApp()).get(
+ `/api/share?sortBy=title&cursor=${encodeURIComponent(cursor)}`,
+ );
+
+ expect(response.status).toBe(200);
+ expect(getSharedLinks).toHaveBeenCalledWith('user-123', cursor, 10, 'title', 'desc', undefined);
+ });
+
+ it('does not expose internal list errors in the response', async () => {
+ getSharedLinks.mockRejectedValue(new Error('mongodb.internal:27017'));
+
+ const response = await request(buildApp()).get('/api/share');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Error getting shared links' });
+ });
+
it('expires new shares for retained non-temporary conversations', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
@@ -291,6 +354,32 @@ describe('share routes', () => {
);
});
+ it('rejects invalid create options before resolving retention', async () => {
+ const invalidTarget = await request(buildApp())
+ .post('/api/share/convo-123')
+ .send({ targetMessageId: '' });
+ const invalidSnapshot = await request(buildApp())
+ .post('/api/share/convo-123')
+ .send({ snapshotFiles: 'false' });
+
+ expect(invalidTarget.status).toBe(400);
+ expect(invalidSnapshot.status).toBe(400);
+ expect(mockGetSharedLinkExpiration).not.toHaveBeenCalled();
+ expect(createSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('maps an existing-share domain error to conflict', async () => {
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ createSharedLink.mockRejectedValue(
+ Object.assign(new Error('Share already exists'), { code: 'SHARE_EXISTS' }),
+ );
+
+ const response = await request(buildApp()).post('/api/share/convo-123').send({});
+
+ expect(response.status).toBe(409);
+ expect(response.body).toEqual({ message: 'Share already exists' });
+ });
+
it('does not snapshot files when the user opts out (snapshotFiles=false)', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
@@ -327,7 +416,9 @@ describe('share routes', () => {
});
it('passes the snapshotFiles opt-out through on update', async () => {
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
@@ -367,7 +458,9 @@ describe('share routes', () => {
});
it('expires updated shares for retained non-temporary conversations', async () => {
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
@@ -403,8 +496,25 @@ describe('share routes', () => {
);
});
+ it('does not re-publish content when re-scoping the grants fails', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
+ mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
+ mockUpdateSharedLinkPermissionsExpiration.mockRejectedValueOnce(new Error('acl down'));
+
+ const response = await request(buildApp()).patch('/api/share/share-123');
+
+ expect(response.status).toBe(500);
+ // The shareId is stable, so publishing first would expose the update behind
+ // the existing grants while the owner is told the update failed.
+ expect(updateSharedLink).not.toHaveBeenCalled();
+ });
+
it('rejects updated shares when the retained conversation expired', async () => {
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
@@ -415,7 +525,9 @@ describe('share routes', () => {
});
it('rejects updated shares for expired conversations in all retention mode', async () => {
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
@@ -432,7 +544,9 @@ describe('share routes', () => {
});
it('clears updated share expiration when the conversation is no longer retained', async () => {
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockResolvedValue(null);
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
@@ -441,11 +555,24 @@ describe('share routes', () => {
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
- expect(mockSharedLinksAccess).not.toHaveBeenCalled();
+ expect(mockSharedLinksAccess).toHaveBeenCalled();
+ });
+
+ it('gates updates on the CREATE permission so revoking it stops link updates', async () => {
+ mockSharedLinksAccess.mockImplementationOnce((_req, res) =>
+ res.status(403).json({ message: 'Forbidden' }),
+ );
+
+ const response = await request(buildApp()).patch('/api/share/share-123');
+
+ expect(response.status).toBe(403);
+ expect(updateSharedLink).not.toHaveBeenCalled();
});
it('preserves updated share expiration when the conversation cannot be found', async () => {
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockResolvedValue(undefined);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
@@ -464,7 +591,9 @@ describe('share routes', () => {
it('clears updated share expiration when creating a new expiration throws', async () => {
const error = new Error('bad config');
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockImplementationOnce(async (_input, dependencies) => {
dependencies.logger.error('[getSharedLinkExpiration] Error creating expiration date:', error);
return null;
@@ -483,7 +612,9 @@ describe('share routes', () => {
});
it('updates share target message while applying retention expiration', async () => {
- mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
+ mongoose.models.SharedLink.findOne.mockReturnValue(
+ lean({ _id: 'link-456', conversationId: 'convo-123' }),
+ );
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456', targetMessageId: 'msg-456' });
@@ -510,6 +641,27 @@ describe('share routes', () => {
expect(updateSharedLink).not.toHaveBeenCalled();
});
+ it('rejects invalid snapshot options on update', async () => {
+ const response = await request(buildApp())
+ .patch('/api/share/share-123')
+ .send({ snapshotFiles: 'false' });
+
+ expect(response.status).toBe(400);
+ expect(updateSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('maps a missing-share update domain error to not found', async () => {
+ mongoose.models.SharedLink.findOne.mockReturnValue(lean(null));
+ updateSharedLink.mockRejectedValue(
+ Object.assign(new Error('Share not found'), { code: 'SHARE_NOT_FOUND' }),
+ );
+
+ const response = await request(buildApp()).patch('/api/share/share-123').send({});
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ message: 'Share not found' });
+ });
+
it('allows deleting existing shares without CREATE permission gate', async () => {
deleteSharedLinkWithCleanup.mockResolvedValue({ shareId: 'share-123' });
@@ -519,6 +671,15 @@ describe('share routes', () => {
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123');
});
+
+ it('returns an internal error status when deletion fails', async () => {
+ deleteSharedLinkWithCleanup.mockRejectedValue(new Error('database unavailable'));
+
+ const response = await request(buildApp()).delete('/api/share/share-123');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Error deleting shared link' });
+ });
});
describe('share fork route', () => {
@@ -537,7 +698,7 @@ describe('share fork route', () => {
buildApp({ user: { id: 'user-123', role: 'USER', tenantId: 'tenant-viewer' } }),
)
.post('/api/share/share-123/fork')
- .send({ targetMessageIndex: 3 });
+ .send({ targetMessageIndex: 3, shareRevision: '2026-01-01T00:00:00.000Z' });
expect(response.status).toBe(201);
expect(response.body).toEqual(forkResult);
@@ -548,6 +709,7 @@ describe('share fork route', () => {
userRole: 'USER',
userTenantId: 'tenant-viewer',
targetMessageIndex: 3,
+ shareRevision: '2026-01-01T00:00:00.000Z',
snapshotFiles: true,
});
});
@@ -581,6 +743,19 @@ describe('share fork route', () => {
expect(response.status).toBe(500);
});
+
+ it('answers 409 when the viewer forks a payload the owner has republished', async () => {
+ const conflict = new Error('Shared link was updated');
+ conflict.code = 'SHARE_REVISION_MISMATCH';
+ forkSharedConversation.mockRejectedValue(conflict);
+
+ const response = await request(buildApp())
+ .post('/api/share/share-123/fork')
+ .send({ targetMessageIndex: 3, shareRevision: '2026-01-01T00:00:00.000Z' });
+
+ expect(response.status).toBe(409);
+ expect(response.body).toEqual({ message: 'Shared link was updated' });
+ });
});
describe('share-scoped file routes', () => {
@@ -618,6 +793,118 @@ describe('share-scoped file routes', () => {
expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
});
+ it('requires revalidation so a revoked link cannot be served from cache', 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',
+ bytes: 1234,
+ },
+ hasSnapshots: true,
+ });
+
+ const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
+
+ expect(response.status).toBe(200);
+ // The shareId is stable across updates now, so the URL alone can no longer bust caches.
+ expect(response.headers['cache-control']).toBe('private, no-cache');
+ expect(response.headers['etag']).toBeDefined();
+ });
+
+ it('answers an unchanged snapshot with 304 instead of re-sending bytes', 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',
+ bytes: 1234,
+ },
+ hasSnapshots: true,
+ });
+
+ const app = buildApp();
+ const first = await request(app).get('/api/share/share-123/files/file-1');
+ expect(first.status).toBe(200);
+
+ getDownloadStream.mockClear();
+ const response = await request(app)
+ .get('/api/share/share-123/files/file-1')
+ .set('If-None-Match', first.headers['etag']);
+
+ expect(response.status).toBe(304);
+ expect(getDownloadStream).not.toHaveBeenCalled();
+ });
+
+ it('changes the validator when the snapshot revision moves', async () => {
+ const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
+ mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
+ const snapshot = {
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/images/owner/pic.png',
+ type: 'image/png',
+ filename: 'pic.png',
+ bytes: 1234,
+ };
+ getSharedLinkFile.mockResolvedValue({ file: snapshot, hasSnapshots: true });
+
+ const app = buildApp();
+ const first = await request(app).get('/api/share/share-123/files/file-1');
+
+ // Must match the snapshot, or resolveShareFile 404s on the version mismatch first.
+ getFiles.mockResolvedValue([{ status: 'ready', previewRevision: 7, bytes: 1234 }]);
+ getSharedLinkFile.mockResolvedValue({
+ file: { ...snapshot, previewRevision: 7 },
+ hasSnapshots: true,
+ });
+
+ const response = await request(app)
+ .get('/api/share/share-123/files/file-1')
+ .set('If-None-Match', first.headers['etag']);
+
+ expect(response.status).toBe(200);
+ expect(response.headers['etag']).not.toBe(first.headers['etag']);
+ });
+
+ it('changes the validator when a same-size replacement moves the stored object', async () => {
+ const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
+ mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
+ const snapshot = {
+ file_id: 'file-1',
+ source: 'local',
+ filepath: '/images/owner/plot.png?v=1',
+ type: 'image/png',
+ filename: 'plot.png',
+ bytes: 1234,
+ };
+ getSharedLinkFile.mockResolvedValue({ file: snapshot, hasSnapshots: true });
+
+ const app = buildApp();
+ const first = await request(app).get('/api/share/share-123/files/file-1');
+
+ // Re-published output: same file_id, same size, no revision, new stored path.
+ getSharedLinkFile.mockResolvedValue({
+ file: { ...snapshot, filepath: '/images/owner/plot.png?v=2' },
+ hasSnapshots: true,
+ });
+
+ const response = await request(app)
+ .get('/api/share/share-123/files/file-1')
+ .set('If-None-Match', first.headers['etag']);
+
+ expect(response.status).toBe(200);
+ expect(response.headers['etag']).not.toBe(first.headers['etag']);
+ });
+
it('forces attachment for unsafe inline types (no stored XSS)', async () => {
const getDownloadStream = jest.fn(async () => Readable.from(['']));
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
@@ -658,6 +945,31 @@ describe('share-scoped file routes', () => {
expect(response.headers['content-disposition']).toContain('attachment');
});
+ it('returns 500 when the backing stream fails before sending bytes', async () => {
+ const failingStream = new Readable({
+ read() {
+ this.destroy(new Error('storage unavailable'));
+ },
+ });
+ mockGetStrategyFunctions.mockReturnValue({
+ getDownloadStream: jest.fn(async () => failingStream),
+ });
+ 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');
+
+ expect(response.status).toBe(500);
+ });
+
it('returns preview status read live from the file record', async () => {
getSharedLinkFile.mockResolvedValue({
file: { file_id: 'file-1', source: 'local' },
diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js
index b38e73b576..9e86a1b0a5 100644
--- a/api/server/routes/convos.js
+++ b/api/server/routes/convos.js
@@ -39,7 +39,8 @@ router.get('/', async (req, res) => {
const limit = parseInt(req.query.limit, 10) || 25;
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
- const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
+ const search =
+ typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
const sortBy = req.query.sortBy || 'updatedAt';
const sortDirection = req.query.sortDirection || 'desc';
const projectId = Array.isArray(req.query.projectId)
diff --git a/api/server/routes/share.js b/api/server/routes/share.js
index 05af670bfb..c44ee8b7e6 100644
--- a/api/server/routes/share.js
+++ b/api/server/routes/share.js
@@ -12,6 +12,10 @@ const {
updateSharedLinkPermissionsExpiration,
isActiveExpirationDate,
getSharedLinkExpiration,
+ buildShareFileEtag,
+ parseSharedLinksPageSize,
+ isValidSharedLinksCursor,
+ MAX_SHARED_LINK_SEARCH_LENGTH,
} = require('@librechat/api');
const {
logger,
@@ -46,6 +50,22 @@ const configMiddleware = require('~/server/middleware/config/app');
const { getAppConfig } = require('~/server/services/Config/app');
const router = express.Router();
+const SHARE_SERVICE_ERROR_STATUS = {
+ INVALID_PARAMS: 400,
+ TARGET_MESSAGE_NOT_FOUND: 400,
+ NO_MESSAGES: 400,
+ CONVERSATION_NOT_FOUND: 404,
+ SHARE_NOT_FOUND: 404,
+ SHARE_EXISTS: 409,
+ SHARE_REVISION_MISMATCH: 409,
+};
+
+const sendShareServiceError = (res, error, fallbackMessage) => {
+ const status = SHARE_SERVICE_ERROR_STATUS[error?.code] ?? 500;
+ const message = status === 500 ? fallbackMessage : error.message;
+ return res.status(status).json({ message });
+};
+
const checkSharedLinksAccess = generateCheckAccess({
permissionType: PermissionTypes.SHARED_LINKS,
permissions: [Permissions.CREATE],
@@ -179,6 +199,16 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
const source = file.source || FileSources.local;
const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source);
+ // An update keeps the shareId, so these URLs are stable across re-publishes. Without
+ // revalidation a viewer's cached copy would outlive a revoked "share files" choice or a
+ // replaced snapshot by the max-age.
+ const etag = buildShareFileEtag(file);
+ res.setHeader('ETag', etag);
+ res.setHeader('Cache-Control', 'private, no-cache');
+ if (req.headers['if-none-match'] === etag) {
+ return res.status(304).end();
+ }
+
// Inline only safe preview types; anything else is forced to attachment.
const disposition =
requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment';
@@ -211,9 +241,6 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
// 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));
@@ -221,8 +248,33 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
'Content-Type',
disposition === 'inline' ? file.type || 'application/octet-stream' : 'application/octet-stream',
);
- res.setHeader('Cache-Control', 'private, max-age=3600');
- return fileStream.pipe(res);
+ return new Promise((resolve, reject) => {
+ const cleanup = () => {
+ fileStream.removeListener('error', onError);
+ res.removeListener('finish', onFinish);
+ res.removeListener('close', onClose);
+ };
+ const onError = (error) => {
+ cleanup();
+ reject(error);
+ };
+ const onFinish = () => {
+ cleanup();
+ resolve();
+ };
+ const onClose = () => {
+ cleanup();
+ if (!fileStream.destroyed) {
+ fileStream.destroy();
+ }
+ resolve();
+ };
+
+ fileStream.once('error', onError);
+ res.once('finish', onFinish);
+ res.once('close', onClose);
+ fileStream.pipe(res);
+ });
};
if (allowSharedLinks) {
@@ -279,6 +331,7 @@ if (allowSharedLinks) {
userRole: req.user.role,
userTenantId: req.user.tenantId,
targetMessageIndex: req.body?.targetMessageIndex,
+ shareRevision: req.body?.shareRevision,
// Viewer-independent: honor the global shared-file kill switch, matching
// the GET share route so disabled file snapshots aren't copied into forks.
snapshotFiles: !isFileSnapshotKillSwitchActive(),
@@ -286,10 +339,12 @@ if (allowSharedLinks) {
if (!result) {
return res.status(404).json({ message: 'Shared conversation not found' });
}
- res.status(201).json(result);
+ return res.status(201).json(result);
} catch (error) {
- logger.error('Error forking shared conversation:', error);
- res.status(500).json({ message: 'Error forking shared conversation' });
+ if (error?.code !== 'SHARE_REVISION_MISMATCH') {
+ logger.error('Error forking shared conversation:', error);
+ }
+ return sendShareServiceError(res, error, 'Error forking shared conversation');
}
},
);
@@ -357,8 +412,9 @@ if (allowSharedLinks) {
} catch (error) {
logger.error('[shareFileAccess] Error downloading shared file:', error);
if (!res.headersSent) {
- res.status(500).send('Error downloading file');
+ return res.status(500).send('Error downloading file');
}
+ res.destroy();
}
},
);
@@ -379,8 +435,9 @@ if (allowSharedLinks) {
} catch (error) {
logger.error('[shareFileAccess] Error serving shared file:', error);
if (!res.headersSent) {
- res.status(500).send('Error serving file');
+ return res.status(500).send('Error serving file');
}
+ res.destroy();
}
},
);
@@ -391,14 +448,31 @@ if (allowSharedLinks) {
*/
router.get('/', requireJwtAuth, async (req, res) => {
try {
+ const sortBy =
+ typeof req.query.sortBy === 'string' && ['createdAt', 'title'].includes(req.query.sortBy)
+ ? req.query.sortBy
+ : 'createdAt';
+ const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.trim() : undefined;
+ const search = typeof req.query.search === 'string' ? req.query.search.trim() : undefined;
+
+ if (search && search.length > MAX_SHARED_LINK_SEARCH_LENGTH) {
+ return res.status(400).json({
+ message: `search must be ${MAX_SHARED_LINK_SEARCH_LENGTH} characters or fewer`,
+ });
+ }
+
+ if (cursor && !isValidSharedLinksCursor(cursor, sortBy)) {
+ return res.status(400).json({ message: 'cursor is not valid for this sort' });
+ }
+
const params = {
- pageParam: req.query.cursor,
- pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
- sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
+ pageParam: cursor,
+ pageSize: parseSharedLinksPageSize(req.query.pageSize),
+ sortBy,
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
: 'desc',
- search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
+ search: search || undefined,
};
const result = await getSharedLinks(
@@ -417,10 +491,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
});
} catch (error) {
logger.error('Error getting shared links:', error);
- res.status(500).json({
- message: 'Error getting shared links',
- error: error.message,
- });
+ res.status(500).json({ message: 'Error getting shared links' });
}
});
@@ -453,7 +524,17 @@ router.post(
checkSharedLinksAccess,
async (req, res) => {
try {
- const { targetMessageId } = req.body;
+ const { targetMessageId, snapshotFiles: requestedSnapshotFiles } = req.body ?? {};
+ if (
+ targetMessageId !== undefined &&
+ (typeof targetMessageId !== 'string' || targetMessageId.trim().length === 0)
+ ) {
+ return res.status(400).json({ message: 'targetMessageId must be a non-empty string' });
+ }
+ if (requestedSnapshotFiles !== undefined && typeof requestedSnapshotFiles !== 'boolean') {
+ return res.status(400).json({ message: 'snapshotFiles must be a boolean' });
+ }
+
const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId);
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
return res.status(404).end();
@@ -464,7 +545,7 @@ router.post(
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 snapshotFiles = isFileSnapshotEnabled(req.config) && requestedSnapshotFiles !== false;
const created = await createSharedLink(
req.user.id,
@@ -481,51 +562,70 @@ router.post(
}
} catch (error) {
logger.error('Error creating shared link:', error);
- res.status(500).json({ message: 'Error creating shared link' });
+ return sendShareServiceError(res, error, 'Error creating shared link');
}
},
);
-router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => {
- try {
- const { targetMessageId } = req.body ?? {};
- if (targetMessageId !== undefined && typeof targetMessageId !== 'string') {
- return res.status(400).json({ message: 'targetMessageId must be a string' });
- }
-
- let expiredAt;
- const SharedLink = mongoose.models.SharedLink;
- const existing = await SharedLink.findOne(
- { shareId: req.params.shareId, user: req.user.id },
- 'conversationId',
- ).lean();
- if (existing?.conversationId) {
- expiredAt = await resolveSharedLinkExpiration(req, existing.conversationId);
- }
- if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
- return res.status(404).end();
- }
-
- const updatedShare = await updateSharedLink(
- req.user.id,
- req.params.shareId,
- targetMessageId,
- expiredAt,
- isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
- );
- if (updatedShare) {
- if (updatedShare._id && expiredAt !== undefined) {
- await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
+/** Updating or re-scoping a link re-publishes conversation content, so it is gated
+ * on the same CREATE permission as POST; revoking CREATE must stop updates too.
+ * DELETE stays ungated so an owner can always retract a link they no longer may create. */
+router.patch(
+ '/:shareId',
+ requireJwtAuth,
+ configMiddleware,
+ checkSharedLinksAccess,
+ async (req, res) => {
+ try {
+ const { targetMessageId, snapshotFiles: requestedSnapshotFiles } = req.body ?? {};
+ if (
+ targetMessageId !== undefined &&
+ (typeof targetMessageId !== 'string' || targetMessageId.trim().length === 0)
+ ) {
+ return res.status(400).json({ message: 'targetMessageId must be a non-empty string' });
}
- res.status(200).json(updatedShare);
- } else {
- res.status(404).end();
+ if (requestedSnapshotFiles !== undefined && typeof requestedSnapshotFiles !== 'boolean') {
+ return res.status(400).json({ message: 'snapshotFiles must be a boolean' });
+ }
+
+ let expiredAt;
+ const SharedLink = mongoose.models.SharedLink;
+ const existing = await SharedLink.findOne(
+ { shareId: req.params.shareId, user: req.user.id },
+ 'conversationId',
+ ).lean();
+ if (existing?.conversationId) {
+ expiredAt = await resolveSharedLinkExpiration(req, existing.conversationId);
+ }
+ if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
+ return res.status(404).end();
+ }
+
+ // Re-scope the grants before re-publishing. The shareId survives an update, so a
+ // failed ACL write after the write-through would leave the new messages and file
+ // snapshot readable at the same URL while the owner is told the update failed.
+ if (existing?._id && expiredAt !== undefined) {
+ await updateSharedLinkPermissionsExpiration(existing._id, expiredAt);
+ }
+
+ const updatedShare = await updateSharedLink(
+ req.user.id,
+ req.params.shareId,
+ targetMessageId,
+ expiredAt,
+ isFileSnapshotEnabled(req.config) && requestedSnapshotFiles !== false,
+ );
+ if (!updatedShare) {
+ return res.status(404).end();
+ }
+
+ return res.status(200).json(updatedShare);
+ } catch (error) {
+ logger.error('Error updating shared link:', error);
+ return sendShareServiceError(res, error, 'Error updating shared link');
}
- } catch (error) {
- logger.error('Error updating shared link:', error);
- res.status(500).json({ message: 'Error updating shared link' });
- }
-});
+ },
+);
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
try {
@@ -538,7 +638,7 @@ router.delete('/:shareId', requireJwtAuth, async (req, res) => {
return res.status(200).json(result);
} catch (error) {
logger.error('Error deleting shared link:', error);
- return res.status(400).json({ message: 'Error deleting shared link' });
+ return res.status(500).json({ message: 'Error deleting shared link' });
}
});
diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js
index b0ba7c0210..1f8112b51f 100644
--- a/api/server/utils/import/fork.js
+++ b/api/server/utils/import/fork.js
@@ -379,6 +379,14 @@ function stripSharedFileIds(message) {
return sanitized;
}
+/** Compares a client-held share revision against the stored one, tolerating the
+ * Date/ISO-string round trip through JSON. */
+function isSameRevision(storedUpdatedAt, clientRevision) {
+ const stored = new Date(storedUpdatedAt ?? 0).getTime();
+ const client = new Date(clientRevision).getTime();
+ return Number.isFinite(stored) && Number.isFinite(client) && stored === client;
+}
+
/**
* Forks a shared (sanitized) conversation into a fresh conversation owned by the requesting user.
* Only the anonymized, allowlisted message fields returned by `getSharedMessages` are cloned,
@@ -390,6 +398,7 @@ function stripSharedFileIds(message) {
* @param {string} [params.userRole] - The role of the requesting user, used to resolve the default model.
* @param {string} [params.userTenantId] - Tenant of the requesting user. `canAccessSharedLink` runs this handler under the share owner's tenant so the share resolves, so the copy must be persisted (and its config/retention resolved) under the requesting user's tenant or it would be invisible (404) when they open it normally.
* @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server.
+ * @param {string} [params.shareRevision] - `updatedAt` of the payload the viewer is forking from. A shareId now survives an update, so an owner republishing between the GET and the fork would silently shift `targetMessageIndex` onto a different branch; a mismatch is rejected instead of cloning content the viewer never saw.
* @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored.
* @param {(userId: string, interfaceConfig?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @param {(options: object) => Promise