Skip to content
10 changes: 10 additions & 0 deletions .changeset/forty-socks-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@rocket.chat/core-services': minor
'@rocket.chat/model-typings': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/models': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Adds a new endpoint to delete uploaded files individually
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export type TypedOptions = {
} & SharedOptions<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>;

export type TypedThis<TOptions extends TypedOptions, TPath extends string = ''> = {
readonly logger: Logger;
userId: TOptions['authRequired'] extends true ? string : string | undefined;
user: TOptions['authRequired'] extends true ? IUser : IUser | null;
token: TOptions['authRequired'] extends true ? string : string | undefined;
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import './v1/email-inbox';
import './v1/mailer';
import './v1/teams';
import './v1/moderation';
import './v1/uploads';

// This has to come last so all endpoints are registered before generating the OpenAPI documentation
import './default/openApi';
Expand Down
99 changes: 99 additions & 0 deletions apps/meteor/app/api/server/v1/uploads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Upload } from '@rocket.chat/core-services';
import type { IUpload } from '@rocket.chat/core-typings';
import { Messages, Uploads, Users } from '@rocket.chat/models';
import {
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateNotFoundErrorResponse,
} from '@rocket.chat/rest-typings';

import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';

type UploadsDeleteResult = {
/**
* The list of files that were successfully removed; May include additional files such as image thumbnails
* */
deletedFiles: IUpload['_id'][];
};

type UploadsDeleteParams = {
fileId: string;
};

const uploadsDeleteParamsSchema = {
type: 'object',
properties: {
fileId: {
type: 'string',
},
},
required: ['fileId'],
additionalProperties: false,
};

export const isUploadsDeleteParams = ajv.compile<UploadsDeleteParams>(uploadsDeleteParamsSchema);

const uploadsDeleteEndpoint = API.v1.post(
'uploads.delete',
{
authRequired: true,
body: isUploadsDeleteParams,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
200: ajv.compile<UploadsDeleteResult>({
type: 'object',
properties: {
success: {
type: 'boolean',
},
deletedFiles: {
description: 'The list of files that were successfully removed. May include additional files such as image thumbnails',
type: 'array',
items: {
type: 'string',
},
},
},
required: ['deletedFiles'],
additionalProperties: false,
}),
},
},
async function action() {
const { fileId } = this.bodyParams;

const file = await Uploads.findOneById(fileId);
if (!file?.userId || !file.rid) {
return API.v1.notFound();
}

const msg = await Messages.getMessageByFileId(fileId);
if (!(await Upload.canDeleteFile(this.userId, file, msg))) {
return API.v1.forbidden('forbidden');
}

const user = await Users.findOneById(this.userId);
// Safeguard, can't really happen
if (!user) {
return API.v1.forbidden('forbidden');
}

const { deletedFiles } = await Upload.deleteFile(user, fileId, msg);
return API.v1.success({
deletedFiles,
});
},
);

type UploadsEndpoints = ExtractRoutesFromAPI<typeof uploadsDeleteEndpoint>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends UploadsEndpoints {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const elapsedTime = (ts: Date): number => {

export const canDeleteMessageAsync = async (
uid: string,
{ u, rid, ts }: { u: Pick<IUser, '_id' | 'username'>; rid: string; ts: Date },
{ u, rid, ts }: { u: Pick<IUser, '_id' | 'username'>; rid: string; ts?: Date },
): Promise<boolean> => {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 'ro' | 'unmuted' | 't' | 'teamId' | 'prid'>>(rid, {
projection: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const parseFileIntoMessageAttachments = async (
image_url: fileUrl,
image_type: file.type as string,
image_size: file.size,
fileId: file._id,
};

if (file.identify?.size) {
Expand Down Expand Up @@ -115,6 +116,7 @@ export const parseFileIntoMessageAttachments = async (
audio_url: fileUrl,
audio_type: file.type as string,
audio_size: file.size,
fileId: file._id,
};
attachments.push(attachment);
} else if (/^video\/.+/.test(file.type as string)) {
Expand All @@ -127,6 +129,7 @@ export const parseFileIntoMessageAttachments = async (
video_url: fileUrl,
video_type: file.type as string,
video_size: file.size as number,
fileId: file._id,
};
attachments.push(attachment);
} else {
Expand All @@ -138,6 +141,7 @@ export const parseFileIntoMessageAttachments = async (
title_link: fileUrl,
title_link_download: true,
size: file.size as number,
fileId: file._id,
};
attachments.push(attachment);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/lib/server/functions/cleanRoomHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.chat/models';

import { deleteRoom } from './deleteRoom';
import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../../lib/constants';
import { i18n } from '../../../../server/lib/i18n';
import { FileUpload } from '../../../file-upload/server';
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener';
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function cleanRoomHistory({
});

const targetMessageIdsForAttachmentRemoval = new Set<string>();
const pruneMessageAttachment = { color: '#FD745E', text };
const pruneMessageAttachment = { color: NOTIFICATION_ATTACHMENT_COLOR, text };

async function performFileAttachmentCleanupBatch() {
if (targetMessageIdsForAttachmentRemoval.size === 0) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const useImagesList = ({ roomId, startingFromId }: { roomId: IRoom['_id']
...file,
uploadedAt: file.uploadedAt ? new Date(file.uploadedAt) : undefined,
modifiedAt: file.modifiedAt ? new Date(file.modifiedAt) : undefined,
expiresAt: file.expiresAt ? new Date(file.expiresAt) : undefined,
}));

for await (const file of items) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const useFilesList = ({ rid, type, text }: { rid: Required<IUpload>['rid'
...file,
uploadedAt: file.uploadedAt ? new Date(file.uploadedAt) : undefined,
modifiedAt: file.modifiedAt ? new Date(file.modifiedAt) : undefined,
expiresAt: file.expiresAt ? new Date(file.expiresAt) : undefined,
}));

for await (const file of items) {
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E';
97 changes: 96 additions & 1 deletion apps/meteor/server/services/upload/service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from '@rocket.chat/core-services';
import type { IUpload, IUser, FilesAndAttachments } from '@rocket.chat/core-typings';
import type { IUpload, IUser, FilesAndAttachments, IMessage } from '@rocket.chat/core-typings';
import { isFileAttachment } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Uploads } from '@rocket.chat/models';

import { canAccessRoomIdAsync } from '../../../app/authorization/server/functions/canAccessRoom';
import { canDeleteMessageAsync } from '../../../app/authorization/server/functions/canDeleteMessage';
import { FileUpload } from '../../../app/file-upload/server';
import { parseFileIntoMessageAttachments, sendFileMessage } from '../../../app/file-upload/server/methods/sendFileMessage';
import { updateMessage } from '../../../app/lib/server/functions/updateMessage';
import { sendFileLivechatMessage } from '../../../app/livechat/server/methods/sendFileLivechatMessage';
import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../lib/constants';
import { i18n } from '../../lib/i18n';

const logger = new Logger('UploadService');

export class UploadService extends ServiceClassInternal implements IUploadService {
protected name = 'upload';
Expand Down Expand Up @@ -38,4 +48,89 @@ export class UploadService extends ServiceClassInternal implements IUploadServic
async parseFileIntoMessageAttachments(file: Partial<IUpload>, roomId: string, user: IUser): Promise<FilesAndAttachments> {
return parseFileIntoMessageAttachments(file, roomId, user);
}

async canDeleteFile(userId: IUser['_id'], file: IUpload, msg: IMessage | null): Promise<boolean> {
if (msg) {
return canDeleteMessageAsync(userId, msg);
}

if (!file.userId || !file.rid) {
return false;
}

// If file is not confirmed and was sent by the same user
if (file.expiresAt && file.userId === userId) {
return canAccessRoomIdAsync(file.rid, userId);
}

// It's a confirmed file but it has no message, so use data from the file to run message delete permission checks
const msgForValidation = { u: { _id: file.userId }, ts: file.uploadedAt, rid: file.rid };
return canDeleteMessageAsync(userId, msgForValidation);
}

async deleteFile(user: IUser, fileId: IUpload['_id'], msg: IMessage | null): Promise<{ deletedFiles: IUpload['_id'][] }> {
// Find every file that is derived from the file that is being deleted (its thumbnails)
const additionalFiles = await Uploads.findAllByOriginalFileId(fileId, { projection: { _id: 1 } })
.map(({ _id }) => _id)
.toArray();
const allFiles = [fileId, ...additionalFiles];

if (msg) {
await this.updateMessageRemovingFiles(msg, allFiles, user);
}

return this.removeFileAndDerivates(fileId, additionalFiles);
}

private async removeFileAndDerivates(
fileId: IUpload['_id'],
additionalFiles: IUpload['_id'][],
): Promise<{ deletedFiles: IUpload['_id'][] }> {
const store = FileUpload.getStore('Uploads');
// Delete the main file first;
await store.deleteById(fileId);

// The main file is already deleted; From here forward we'll return a success response even if some sub-process fails
const deletedFiles: IUpload['_id'][] = [fileId];
// Delete them one by one as the store may include requests to external services
for await (const id of additionalFiles) {
try {
await store.deleteById(id);
deletedFiles.push(id);
} catch (err) {
logger.error({ msg: 'Failed to delete derived file', fileId: id, originalFileId: fileId, err });
}
}

return { deletedFiles };
}

private async updateMessageRemovingFiles(msg: IMessage, filesToRemove: IUpload['_id'][], user: IUser): Promise<void> {
const text = `_${i18n.t('File_removed')}_`;
const newAttachment = { color: NOTIFICATION_ATTACHMENT_COLOR, text };

const newFiles = msg.files?.filter((file) => !filesToRemove.includes(file._id));
const newAttachments = msg.attachments?.map((attachment) => {
if (!isFileAttachment(attachment)) {
return attachment;
}

// If the attachment doesn't have a `fileId`, we assume it's an old message with only one file, in which case checking the id is not needed
if (attachment.fileId && !filesToRemove.includes(attachment.fileId)) {
return attachment;
}

return newAttachment;
});
const newFile = msg.file?._id && !filesToRemove.includes(msg.file._id) ? msg.file : newFiles?.[0];

const editedMessage = {
...msg,
files: newFiles,
attachments: newAttachments,
file: newFile,
};

await updateMessage(editedMessage, user, msg);
}
}
2 changes: 2 additions & 0 deletions packages/core-services/src/types/IUploadService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ export interface IUploadService {
getFileBuffer({ file }: { file: IUpload }): Promise<Buffer>;
extractMetadata(file: IUpload): Promise<{ height?: number; width?: number; format?: string }>;
parseFileIntoMessageAttachments(file: Partial<IUpload>, roomId: string, user: IUser): Promise<FilesAndAttachments>;
canDeleteFile(userId: IUser['_id'], file: IUpload, msg: IMessage | null): Promise<boolean>;
deleteFile(user: IUser, fileId: IUpload['_id'], msg: IMessage | null): Promise<{ deletedFiles: IUpload['_id'][] }>;
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import type { MessageAttachmentBase } from '../MessageAttachmentBase';
import type { AudioAttachmentProps } from './AudioAttachmentProps';
import type { FileProp } from './FileProp';
import type { ImageAttachmentProps } from './ImageAttachmentProps';
import type { VideoAttachmentProps } from './VideoAttachmentProps';

type CommonFileProps = {
type: 'file';
fileId?: FileProp['_id'];
};

export type FileAttachmentProps =
| ({ type: 'file' } & VideoAttachmentProps)
| ({ type: 'file' } & ImageAttachmentProps)
| ({ type: 'file' } & AudioAttachmentProps)
| ({ type: 'file' } & MessageAttachmentBase);
| (CommonFileProps & VideoAttachmentProps)
| (CommonFileProps & ImageAttachmentProps)
| (CommonFileProps & AudioAttachmentProps)
| (CommonFileProps & MessageAttachmentBase);

export const isFileAttachment = (attachment: MessageAttachmentBase): attachment is FileAttachmentProps =>
'type' in attachment && (attachment as any).type === 'file';
1 change: 1 addition & 0 deletions packages/core-typings/src/IUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface IUpload {
token?: string;
uploadedAt?: Date;
modifiedAt?: Date;
expiresAt?: Date;
url?: string;
originalStore?: string;
originalId?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,7 @@
"File_not_allowed_direct_messages": "File sharing not allowed in direct messages.",
"File_removed_by_automatic_prune": "File removed by automatic prune",
"File_removed_by_prune": "File removed by prune",
"File_removed": "File removed",
"File_type_is_not_accepted": "File type is not accepted.",
"File_uploaded": "File uploaded",
"File_uploaded_successfully": "File uploaded successfully",
Expand Down
2 changes: 2 additions & 0 deletions packages/model-typings/src/models/IUploadsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ export interface IUploadsModel extends IBaseUploadsModel<IUpload> {
findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise<IUpload | null>;

setFederationInfo(fileId: IUpload['_id'], info: Required<IUpload>['federation']): Promise<UpdateResult>;

findAllByOriginalFileId(originalFileId: string, options?: FindOptions<IUpload>): FindCursor<IUpload>;
}
4 changes: 4 additions & 0 deletions packages/models/src/models/Uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,8 @@ export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel {
},
);
}

findAllByOriginalFileId(originalFileId: string, options: FindOptions<IUpload> = {}): FindCursor<IUpload> {
return this.find({ originalFileId }, options);
}
}
Loading