mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 16:23:44 +00:00
Project membership writes bypassed forced retention: assigning a chat to a project, removing it, or deleting the project rewrote the conversation row (chatProjectId) without setting isTemporary/expiredAt. On a deployment that switched to ephemeral mode, touching an older permanent chat this way left it visible and non-expiring. Load the interface config on the assign and delete project routes and cascade forced retention through the touched conversation(s): assign/remove caps the single conversation, its messages, and its shares; delete caps every member chat before unassigning. Extract the bucketed tag cascade into a shared helper so the project cascade reuses the same conversion/backfill/cap logic, and add a shared resolveForcedRetentionDate helper. All paths are a no-op outside forced retention, so non-ephemeral behavior is unchanged.
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import { isValidObjectIdString, logger } from '@librechat/data-schemas';
|
|
|
|
import type {
|
|
AppConfig,
|
|
ChatProjectMethods,
|
|
ChatProjectSortBy,
|
|
ChatProjectSortDirection,
|
|
CreateChatProjectInput,
|
|
UpdateChatProjectInput,
|
|
} from '@librechat/data-schemas';
|
|
import type { Request, Response } from 'express';
|
|
|
|
const PROJECT_NOT_FOUND = 'Project not found';
|
|
const CONVERSATION_NOT_FOUND = 'Conversation not found';
|
|
|
|
const PROJECT_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
|
|
|
|
interface ProjectUser {
|
|
id: string;
|
|
_id?: {
|
|
toString(): string;
|
|
};
|
|
}
|
|
|
|
interface ProjectRequest extends Request {
|
|
user?: ProjectUser;
|
|
config?: AppConfig;
|
|
}
|
|
|
|
type ProjectHandlerDependencies = Pick<
|
|
ChatProjectMethods,
|
|
| 'listChatProjects'
|
|
| 'createChatProject'
|
|
| 'getChatProject'
|
|
| 'updateChatProject'
|
|
| 'deleteChatProject'
|
|
| 'assignConversationToProject'
|
|
>;
|
|
|
|
const getUserId = (req: ProjectRequest): string => req.user?.id ?? req.user?._id?.toString() ?? '';
|
|
|
|
const queryString = (value: Request['query'][string]): string | undefined => {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return queryString(value[0]);
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const normalizeString = (value: string | null | undefined): string =>
|
|
typeof value === 'string' ? value.trim() : '';
|
|
|
|
const normalizeLimit = (value: Request['query'][string]): number => {
|
|
const limit = parseInt(queryString(value) ?? '', 10);
|
|
if (!Number.isFinite(limit)) {
|
|
return 25;
|
|
}
|
|
return Math.min(Math.max(limit, 1), 100);
|
|
};
|
|
|
|
const normalizeSortBy = (value: Request['query'][string]): ChatProjectSortBy | undefined => {
|
|
const sortBy = queryString(value);
|
|
return PROJECT_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
|
|
? (sortBy as ChatProjectSortBy)
|
|
: undefined;
|
|
};
|
|
|
|
const normalizeSortDirection = (
|
|
value: Request['query'][string],
|
|
): ChatProjectSortDirection | undefined => {
|
|
const sortDirection = queryString(value);
|
|
return sortDirection === 'asc' || sortDirection === 'desc' ? sortDirection : undefined;
|
|
};
|
|
|
|
const createProjectInput = (req: ProjectRequest): CreateChatProjectInput | null => {
|
|
const name = normalizeString(req.body?.name);
|
|
if (!name) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
name,
|
|
description: typeof req.body?.description === 'string' ? req.body.description : '',
|
|
};
|
|
};
|
|
|
|
export function createProjectHandlers(deps: ProjectHandlerDependencies): {
|
|
listProjects: (req: ProjectRequest, res: Response) => Promise<Response>;
|
|
createProject: (req: ProjectRequest, res: Response) => Promise<Response>;
|
|
assignConversationToProject: (req: ProjectRequest, res: Response) => Promise<Response>;
|
|
getProject: (req: ProjectRequest, res: Response) => Promise<Response>;
|
|
updateProject: (req: ProjectRequest, res: Response) => Promise<Response>;
|
|
deleteProject: (req: ProjectRequest, res: Response) => Promise<Response>;
|
|
} {
|
|
async function listProjects(req: ProjectRequest, res: Response): Promise<Response> {
|
|
try {
|
|
const result = await deps.listChatProjects(getUserId(req), {
|
|
cursor: queryString(req.query.cursor),
|
|
limit: normalizeLimit(req.query.limit),
|
|
sortBy: normalizeSortBy(req.query.sortBy),
|
|
sortDirection: normalizeSortDirection(req.query.sortDirection),
|
|
search: queryString(req.query.search),
|
|
});
|
|
return res.status(200).json(result);
|
|
} catch (error) {
|
|
logger.error('[projects] Error listing projects', error);
|
|
return res.status(500).json({ error: 'Error listing projects' });
|
|
}
|
|
}
|
|
|
|
async function createProject(req: ProjectRequest, res: Response): Promise<Response> {
|
|
const input = createProjectInput(req);
|
|
if (!input) {
|
|
return res.status(400).json({ error: 'name is required' });
|
|
}
|
|
|
|
try {
|
|
const project = await deps.createChatProject(getUserId(req), input);
|
|
return res.status(201).json(project);
|
|
} catch (error) {
|
|
logger.error('[projects] Error creating project', error);
|
|
return res.status(500).json({ error: 'Error creating project' });
|
|
}
|
|
}
|
|
|
|
async function assignConversationToProject(
|
|
req: ProjectRequest,
|
|
res: Response,
|
|
): Promise<Response> {
|
|
const { conversationId } = req.params;
|
|
const projectId = req.body?.projectId ?? null;
|
|
|
|
if (projectId !== null && typeof projectId !== 'string') {
|
|
return res.status(400).json({ error: 'projectId must be a string or null' });
|
|
}
|
|
|
|
try {
|
|
const result = await deps.assignConversationToProject(
|
|
getUserId(req),
|
|
conversationId,
|
|
projectId,
|
|
req.config?.interfaceConfig,
|
|
);
|
|
if (!result) {
|
|
return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
|
|
}
|
|
return res.status(200).json(result);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === PROJECT_NOT_FOUND) {
|
|
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
|
}
|
|
logger.error('[projects] Error assigning conversation to project', error);
|
|
return res.status(500).json({ error: 'Error assigning conversation to project' });
|
|
}
|
|
}
|
|
|
|
async function getProject(req: ProjectRequest, res: Response): Promise<Response> {
|
|
const { projectId } = req.params;
|
|
if (!isValidObjectIdString(projectId)) {
|
|
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
|
}
|
|
|
|
try {
|
|
const project = await deps.getChatProject(getUserId(req), projectId);
|
|
if (!project) {
|
|
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
|
}
|
|
return res.status(200).json(project);
|
|
} catch (error) {
|
|
logger.error('[projects] Error getting project', error);
|
|
return res.status(500).json({ error: 'Error getting project' });
|
|
}
|
|
}
|
|
|
|
async function updateProject(req: ProjectRequest, res: Response): Promise<Response> {
|
|
const { projectId } = req.params;
|
|
if (!isValidObjectIdString(projectId)) {
|
|
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
|
}
|
|
|
|
const input: UpdateChatProjectInput = {};
|
|
if (req.body?.name !== undefined) {
|
|
const name = normalizeString(req.body.name);
|
|
if (!name) {
|
|
return res.status(400).json({ error: 'name is required' });
|
|
}
|
|
input.name = name;
|
|
}
|
|
if (req.body?.description !== undefined) {
|
|
input.description = typeof req.body.description === 'string' ? req.body.description : '';
|
|
}
|
|
|
|
try {
|
|
const project = await deps.updateChatProject(getUserId(req), projectId, input);
|
|
if (!project) {
|
|
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
|
}
|
|
return res.status(200).json(project);
|
|
} catch (error) {
|
|
logger.error('[projects] Error updating project', error);
|
|
return res.status(500).json({ error: 'Error updating project' });
|
|
}
|
|
}
|
|
|
|
async function deleteProject(req: ProjectRequest, res: Response): Promise<Response> {
|
|
const { projectId } = req.params;
|
|
if (!isValidObjectIdString(projectId)) {
|
|
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
|
}
|
|
|
|
try {
|
|
const result = await deps.deleteChatProject(
|
|
getUserId(req),
|
|
projectId,
|
|
req.config?.interfaceConfig,
|
|
);
|
|
if (!result.deletedCount) {
|
|
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
|
}
|
|
return res.status(200).json(result);
|
|
} catch (error) {
|
|
logger.error('[projects] Error deleting project', error);
|
|
return res.status(500).json({ error: 'Error deleting project' });
|
|
}
|
|
}
|
|
|
|
return {
|
|
listProjects,
|
|
createProject,
|
|
assignConversationToProject,
|
|
getProject,
|
|
updateProject,
|
|
deleteProject,
|
|
};
|
|
}
|