From 829c5d1bfb41331d4c990d2d1caf8ac13e3eca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=B3=E3=83=A1=E3=83=BC=E3=83=A9?= <39674991+vynmera@users.noreply.github.com> Date: Fri, 20 Jul 2018 06:26:33 +0200 Subject: [PATCH] [NEW][BREAK] Message retention policy and pruning (#11236) Closes #6749 Closes #8321 Closes #9374 Closes #2700 Closes #2639 Closes #2355 Closes #1861 Closes #8757 Closes #7228 Closes #10870 Closes #6193 Closes #11299 Closes #11468 Closes #9317 Closes #11300 (will incorporate a fix to this PR's issue) Closes #11046 (will incorporate a fix to this PR's issue) Contributes to #5944 Contributes to #11475 _...and possibly more!_ This PR makes deleting messages (automatically and manually) a lot easier on Rocket.Chat. - [X] Implement a bulk message deletion notification, to quickly push large message deletions to users without reload - [X] Use it in `rooms.cleanHistory` - [X] Use it in user deletions - [X] Completely remove cleanChannelHistory as required by v0.67 - [X] Remove server method `cleanChannelHistory` - [X] Remove REST API `channels.cleanHistory` - [x] Implement a sidebar option to clean history - [x] Basic implementation - [x] Allow excluding pinned messages - [x] Allow attachment-only mode - [x] Allow specifying user(s) to narrow down to - [x] Also update REST API - [x] Also update docs - [x] Break the deletion into multiple different requests, so the client can keep track of progress - [x] Clear animation / progress bar for deleting - [x] Retention policy - [X] Global, set by admin - [X] Global timer that runs every second and deletes messages over the set limit - [X] Can change its timer's resolution to prevent insane CPU overhead - [X] Admin can decide what room types to target (channels, groups and/or DMs) - [X] Allow excluding pinned messages - [X] Allow attachment-only mode - [x] Per-channel, set by those with a new permission - [x] Disabled when master switch off - [x] Set in channel info - [x] Can override global policy with a switch that requires `edit-privileged-setting` - [x] Allow excluding pinned messages - [x] Allow attachment-only mode - [x] Uses same global timer for cleanup - [X] Message at start of channel history / in channel info if there is a retention policy set - [x] Message in channel info if there is a retention policy set on that channel specifically - [X] Make cleaning history also delete files (completely!) - [X] Manual purging - [X] Automatic purging - [x] Make other deletions also delete files - [x] User deletion - [X] Own messages - [x] DMs with them's partner messages - [x] Room deletion - [x] Cleanup - [x] Finish related [docs](https://github.com/RocketChat/docs/pull/815) - [x] Link to the docs in the settings Please suggest any cool changes/additions! Any support is greatly appreciated. **Breaking change:** This PR removes REST API endpoint `channels.cleanHistory` and Meteor callable `cleanChannelHistory` as per the protocol specified for them.  --- .meteor/packages | 2 + .meteor/versions | 2 + packages/rocketchat-api/server/v1/channels.js | 35 -- packages/rocketchat-api/server/v1/rooms.js | 2 +- .../server/startup.js | 3 +- .../client/views/channelSettings.html | 118 +++++- .../client/views/channelSettings.js | 311 +++++++++++++++- .../server/methods/saveRoomSettings.js | 62 ++- packages/rocketchat-i18n/i18n/en.i18n.json | 62 +++ packages/rocketchat-lib/client/UserDeleted.js | 7 + packages/rocketchat-lib/package.js | 3 +- .../server/functions/cleanRoomHistory.js | 43 +++ .../server/functions/deleteUser.js | 18 +- .../server/methods/cleanChannelHistory.js | 10 - .../server/methods/cleanRoomHistory.js | 32 +- .../rocketchat-lib/server/models/Messages.js | 83 +++++ .../rocketchat-lib/server/models/Rooms.js | 67 ++++ .../rocketchat-retention-policy/README.md | 0 .../rocketchat-retention-policy/package.js | 25 ++ .../server/cronPruneMessages.js | 123 ++++++ .../server/startup/settings.js | 107 ++++++ .../client/imports/general/base_old.css | 8 + .../rocketchat-ui-clean-history/README.md | 0 .../client/lib/startup.js | 12 + .../client/views/cleanHistory.html | 142 +++++++ .../client/views/cleanHistory.js | 352 ++++++++++++++++++ .../client/views/stylesheets/cleanHistory.css | 52 +++ .../rocketchat-ui-clean-history/package.js | 27 ++ .../rocketchat-ui-master/public/icons.svg | 14 + .../rocketchat-ui/client/lib/RoomManager.js | 19 +- packages/rocketchat-ui/client/lib/tapi18n.js | 4 +- .../rocketchat-ui/client/views/app/room.html | 19 + .../rocketchat-ui/client/views/app/room.js | 94 +++++ server/methods/eraseRoom.js | 23 +- server/publications/room.js | 1 + tests/end-to-end/api/02-channels.js | 18 - 36 files changed, 1769 insertions(+), 131 deletions(-) create mode 100644 packages/rocketchat-lib/client/UserDeleted.js create mode 100644 packages/rocketchat-lib/server/functions/cleanRoomHistory.js delete mode 100644 packages/rocketchat-lib/server/methods/cleanChannelHistory.js create mode 100644 packages/rocketchat-retention-policy/README.md create mode 100644 packages/rocketchat-retention-policy/package.js create mode 100644 packages/rocketchat-retention-policy/server/cronPruneMessages.js create mode 100644 packages/rocketchat-retention-policy/server/startup/settings.js create mode 100644 packages/rocketchat-ui-clean-history/README.md create mode 100644 packages/rocketchat-ui-clean-history/client/lib/startup.js create mode 100644 packages/rocketchat-ui-clean-history/client/views/cleanHistory.html create mode 100644 packages/rocketchat-ui-clean-history/client/views/cleanHistory.js create mode 100644 packages/rocketchat-ui-clean-history/client/views/stylesheets/cleanHistory.css create mode 100644 packages/rocketchat-ui-clean-history/package.js diff --git a/.meteor/packages b/.meteor/packages index bbe5d3bd8b7..841134b67d8 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -110,6 +110,7 @@ rocketchat:otr rocketchat:postcss rocketchat:push-notifications rocketchat:reactions +rocketchat:retention-policy rocketchat:apps rocketchat:sandstorm rocketchat:setup-wizard @@ -142,6 +143,7 @@ rocketchat:tutum rocketchat:ui rocketchat:ui-account rocketchat:ui-admin +rocketchat:ui-clean-history rocketchat:ui-flextab rocketchat:ui-login rocketchat:ui-master diff --git a/.meteor/versions b/.meteor/versions index a9bb91cf018..3c16d18873d 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -201,6 +201,7 @@ rocketchat:otr@0.0.1 rocketchat:postcss@1.0.0 rocketchat:push-notifications@0.0.1 rocketchat:reactions@0.0.1 +rocketchat:retention-policy@0.0.1 rocketchat:sandstorm@0.0.1 rocketchat:search@0.0.1 rocketchat:setup-wizard@0.0.1 @@ -234,6 +235,7 @@ rocketchat:tutum@0.0.1 rocketchat:ui@0.1.0 rocketchat:ui-account@0.1.0 rocketchat:ui-admin@0.1.0 +rocketchat:ui-clean-history@0.0.1 rocketchat:ui-flextab@0.1.0 rocketchat:ui-login@0.1.0 rocketchat:ui-master@0.1.0 diff --git a/packages/rocketchat-api/server/v1/channels.js b/packages/rocketchat-api/server/v1/channels.js index 3c13e3e94a3..61d3961a40f 100644 --- a/packages/rocketchat-api/server/v1/channels.js +++ b/packages/rocketchat-api/server/v1/channels.js @@ -80,41 +80,6 @@ RocketChat.API.v1.addRoute('channels.archive', { authRequired: true }, { } }); -/** - DEPRECATED - // TODO: Remove this after three versions have been released. That means at 0.67 this should be gone. - **/ -RocketChat.API.v1.addRoute('channels.cleanHistory', { authRequired: true }, { - post() { - const findResult = findChannelByIdOrName({ params: this.requestParams() }); - - if (!this.bodyParams.latest) { - return RocketChat.API.v1.failure('Body parameter "latest" is required.'); - } - - if (!this.bodyParams.oldest) { - return RocketChat.API.v1.failure('Body parameter "oldest" is required.'); - } - - const latest = new Date(this.bodyParams.latest); - const oldest = new Date(this.bodyParams.oldest); - - let inclusive = false; - if (typeof this.bodyParams.inclusive !== 'undefined') { - inclusive = this.bodyParams.inclusive; - } - - Meteor.runAsUser(this.userId, () => { - Meteor.call('cleanChannelHistory', { roomId: findResult._id, latest, oldest, inclusive }); - }); - - return RocketChat.API.v1.success(this.deprecationWarning({ - endpoint: 'channels.cleanHistory', - versionWillBeRemove: 'v0.67' - })); - } -}); - RocketChat.API.v1.addRoute('channels.close', { authRequired: true }, { post() { const findResult = findChannelByIdOrName({ params: this.requestParams(), checkedArchived: false }); diff --git a/packages/rocketchat-api/server/v1/rooms.js b/packages/rocketchat-api/server/v1/rooms.js index 2f05681c66f..1f9f46581e2 100644 --- a/packages/rocketchat-api/server/v1/rooms.js +++ b/packages/rocketchat-api/server/v1/rooms.js @@ -177,7 +177,7 @@ RocketChat.API.v1.addRoute('rooms.cleanHistory', { authRequired: true }, { } Meteor.runAsUser(this.userId, () => { - Meteor.call('cleanRoomHistory', { roomId: findResult._id, latest, oldest, inclusive }); + Meteor.call('cleanRoomHistory', { roomId: findResult._id, latest, oldest, inclusive, limit: this.bodyParams.limit, excludePinned: this.bodyParams.excludePinned, filesOnly: this.bodyParams.filesOnly, fromUsers: this.bodyParams.users }); }); return RocketChat.API.v1.success(); diff --git a/packages/rocketchat-authorization/server/startup.js b/packages/rocketchat-authorization/server/startup.js index 85c2d02a374..6827f71da1f 100644 --- a/packages/rocketchat-authorization/server/startup.js +++ b/packages/rocketchat-authorization/server/startup.js @@ -20,7 +20,7 @@ Meteor.startup(function() { { _id: 'create-d', roles : ['admin', 'user', 'bot'] }, { _id: 'create-p', roles : ['admin', 'user', 'bot'] }, { _id: 'create-user', roles : ['admin'] }, - { _id: 'clean-channel-history', roles : ['admin'] }, // special permission to bulk delete a channel's mesages + { _id: 'clean-channel-history', roles : ['admin'] }, { _id: 'delete-c', roles : ['admin', 'owner'] }, { _id: 'delete-d', roles : ['admin'] }, { _id: 'delete-message', roles : ['admin', 'owner', 'moderator'] }, @@ -32,6 +32,7 @@ Meteor.startup(function() { { _id: 'edit-other-user-password', roles : ['admin'] }, { _id: 'edit-privileged-setting', roles : ['admin'] }, { _id: 'edit-room', roles : ['admin', 'owner', 'moderator'] }, + { _id: 'edit-room-retention-policy', roles : ['admin'] }, { _id: 'force-delete-message', roles : ['admin', 'owner'] }, { _id: 'join-without-join-code', roles : ['admin', 'bot'] }, { _id: 'leave-c', roles : ['admin', 'user', 'bot', 'anonymous'] }, diff --git a/packages/rocketchat-channel-settings/client/views/channelSettings.html b/packages/rocketchat-channel-settings/client/views/channelSettings.html index d19d3e8208d..0dc56187495 100644 --- a/packages/rocketchat-channel-settings/client/views/channelSettings.html +++ b/packages/rocketchat-channel-settings/client/views/channelSettings.html @@ -117,12 +117,11 @@ {{/with}} - {{#with settings.reactWhenReadOnly}} {{#if canView}}
%s
",
"Exclude_Botnames": "Exclude Bots",
"Exclude_Botnames_Description": "Do not propagate messages from bots whose name matches the regular expression above. If left empty, all messages from bots will be propagated.",
+ "Exclude_pinned": "Exclude pinned messages",
+ "except_pinned": "(except those that are pinned)",
"Execute_Synchronization_Now": "Execute Synchronization Now",
"Export_My_Data": "Export My Data",
"External_Queue_Service_URL": "External Queue Service URL",
@@ -1115,6 +1125,8 @@
"File_exceeds_allowed_size_of_bytes": "File exceeds allowed size of __size__.",
"File_name_Placeholder": "Search files...",
"File_not_allowed_direct_messages": "File sharing not allowed in direct messages.",
+ "File_removed_by_prune": "File removed by prune",
+ "File_removed_by_automatic_prune": "File removed by automatic prune",
"File_type_is_not_accepted": "File type is not accepted.",
"File_uploaded": "File uploaded",
"FileUpload": "File Upload",
@@ -1165,6 +1177,8 @@
"FileUpload_Webdav_Proxy_Avatars_Description": "Proxy avatar file transmissions through your server instead of direct access to the asset's URL",
"FileUpload_Webdav_Proxy_Uploads": "Proxy Uploads",
"FileUpload_Webdav_Proxy_Uploads_Description": "Proxy upload file transmissions through your server instead of direct access to the asset's URL",
+ "files": "files",
+ "Files_only": "Only remove the attached files, keep messages",
"Financial_Services": "Financial Services",
"First_Channel_After_Login": "First Channel After Login",
"Flags": "Flags",
@@ -1201,6 +1215,7 @@
"Give_a_unique_name_for_the_custom_oauth": "Give a unique name for the custom oauth",
"Give_the_application_a_name_This_will_be_seen_by_your_users": "Give the application a name. This will be seen by your users.",
"Global": "Global",
+ "Global_purge_override_warning": "A global retention policy is in place. If you leave \"Override global retention policy\" off, you can only apply a policy that is stricter than the global policy.",
"Global_Search": "Global search",
"Go_to_your_workspace": "Go to your workspace",
"Google_Vision_usage_limit_exceeded": "Google Vision usage limit exceeded",
@@ -1263,6 +1278,7 @@
"How_to_handle_open_sessions_when_agent_goes_offline": "How to Handle Open Sessions When Agent Goes Offline",
"Idle_Time_Limit": "Idle Time Limit",
"Idle_Time_Limit_Description": "Period of time until status changes to away. Value needs to be in seconds.",
+ "if_they_are_from": "(if they are from %s)",
"If_this_email_is_registered": "If this email is registered, we'll send instructions on how to reset your password. If you do not receive an email shortly, please come back and try again.",
"If_you_are_sure_type_in_your_password": "If you are sure type in your password:",
"If_you_are_sure_type_in_your_username": "If you are sure type in your username:",
@@ -1307,6 +1323,7 @@
"Importer_setup_error": "An error occurred while setting up the importer.",
"Importer_Slack_Users_CSV_Information": "The file uploaded must be Slack's Users export file, which is a CSV file. See here for more information:",
"Importer_Source_File": "Source File Selection",
+ "Inclusive": "Inclusive",
"Incoming_Livechats": "Incoming Livechats",
"Incoming_WebHook": "Incoming WebHook",
"Industry": "Industry",
@@ -1753,6 +1770,7 @@
"Message_VideoRecorderEnabledDescription": "Requires 'video/webm' files to be an accepted media type within 'File Upload' settings.",
"Message_view_mode_info": "This changes the amount of space messages take up on screen.",
"Messages": "Messages",
+ "Mmessages": "messages",
"Messages_that_are_sent_to_the_Incoming_WebHook_will_be_posted_here": "Messages that are sent to the Incoming WebHook will be posted here.",
"Meta": "Meta",
"Meta_custom": "Custom Meta Tags",
@@ -1814,6 +1832,8 @@
"New_version_available_(s)": "New version available (%s)",
"New_videocall_request": "New Video Call Request",
"New_visitor_navigation": "New Navigation: __history__",
+ "Newer_than": "Newer than",
+ "Newer_than_may_not_exceed_Older_than": "\"Newer than\" may not exceed \"Older than\"",
"No_available_agents_to_transfer": "No available agents to transfer",
"No_channel_with_name_%s_was_found": "No channel with name \"%s\" was found!",
"No_channels_yet": "You aren't part of any channel yet",
@@ -1879,10 +1899,12 @@
"Offline_message": "Offline message",
"Offline_success_message": "Offline Success Message",
"Offline_unavailable": "Offline unavailable",
+ "Older_than": "Older than",
"On": "On",
"Online": "Online",
"online": "online",
"Only_authorized_users_can_write_new_messages": "Only authorized users can write new messages",
+ "Only_from_users": "Only prune content from these users (leave empty to prune everyone's content)",
"Only_On_Desktop": "Desktop mode (only sends with enter on desktop)",
"Only_you_can_see_this_message": "Only you can see this message",
"Oops!": "Oops",
@@ -1989,6 +2011,18 @@
"Profile_details": "Profile Details",
"Profile_picture": "Profile Picture",
"Profile_saved_successfully": "Profile saved successfully",
+ "Prune": "Prune",
+ "Prune_finished": "Prune finished",
+ "Prune_Messages": "Prune Messages",
+ "Prune_Modal": "Are you sure you wish to prune these messages? Pruned messages cannot be recovered.",
+ "Prune_Warning_all": "This will delete all %s in %s!",
+ "Prune_Warning_before": "This will delete all %s in %s before %s.",
+ "Prune_Warning_after": "This will delete all %s in %s after %s.",
+ "Prune_Warning_between": "This will delete all %s in %s between %s and %s.",
+ "Pruning_messages": "Pruning messages...",
+ "Pruning_files": "Pruning files...",
+ "messages_pruned": "messages pruned",
+ "files_pruned": "files pruned",
"Public": "Public",
"Public_Channel": "Public Channel",
"Public_Community": "Public Community",
@@ -2089,6 +2123,33 @@
"Restart": "Restart",
"Restart_the_server": "Restart the server",
"Retail": "Retail",
+ "Retention_setting_changed_successfully": "Retention policy setting changed successfully",
+ "RetentionPolicy": "Retention Policy",
+ "RetentionPolicy_RoomWarning": "Messages older than %s are automatically pruned here",
+ "RetentionPolicy_RoomWarning_Unpinned": "Unpinned messages older than %s are automatically pruned here",
+ "RetentionPolicy_RoomWarning_FilesOnly": "Files older than %s are automatically pruned here (messages stay intact)",
+ "RetentionPolicy_RoomWarning_UnpinnedFilesOnly": "Unpinned files older than %s are automatically pruned here (messages stay intact)",
+ "RetentionPolicy_Description": "Automatically prunes old messages across your Rocket.Chat instance.",
+ "RetentionPolicy_Enabled": "Enabled",
+ "RetentionPolicy_AppliesToChannels": "Applies to channels",
+ "RetentionPolicy_AppliesToGroups": "Applies to private groups",
+ "RetentionPolicy_AppliesToDMs": "Applies to direct messages",
+ "RetentionPolicy_ExcludePinned": "Exclude pinned messages",
+ "RetentionPolicy_FilesOnly": "Only delete files",
+ "RetentionPolicy_FilesOnly_Description": "Only files will be deleted, the messages themselves will stay in place.",
+ "RetentionPolicy_MaxAge": "Maximum message age",
+ "RetentionPolicy_MaxAge_Channels": "Maximum message age in channels",
+ "RetentionPolicy_MaxAge_Groups": "Maximum message age in private groups",
+ "RetentionPolicy_MaxAge_DMs": "Maximum message age in direct messages",
+ "RetentionPolicy_MaxAge_Description": "Prune all messages older than this value, in days",
+ "RetentionPolicy_Precision": "Timer Precision",
+ "RetentionPolicy_Precision_Description": "How often the prune timer should run. Setting this to a more precise value makes channels with fast retention timers work better, but might cost extra processing power on large communities.",
+ "RetentionPolicyRoom_Enabled": "Automatically prune old messages",
+ "RetentionPolicyRoom_ExcludePinned": "Exclude pinned messages",
+ "RetentionPolicyRoom_FilesOnly": "Prune files only, keep messages",
+ "RetentionPolicyRoom_MaxAge": "Maximum message age in days (default: __max__)",
+ "RetentionPolicyRoom_OverrideGlobal": "Override global retention policy",
+ "RetentionPolicyRoom_ReadTheDocs": "Watch out! Tweaking these settings without utmost care can destroy all message history. Please read the documentation before turning the feature on here.",
"Retry_Count": "Retry Count",
"Role": "Role",
"Role_Editing": "Role Editing",
@@ -2717,6 +2778,7 @@
"Yes_hide_it": "Yes, hide it!",
"Yes_leave_it": "Yes, leave it!",
"Yes_mute_user": "Yes, mute user!",
+ "Yes_prune_them": "Yes, prune them!",
"Yes_remove_user": "Yes, remove user!",
"Yes_unarchive_it": "Yes, unarchive it!",
"yesterday": "yesterday",
diff --git a/packages/rocketchat-lib/client/UserDeleted.js b/packages/rocketchat-lib/client/UserDeleted.js
new file mode 100644
index 00000000000..07f61cfcc0c
--- /dev/null
+++ b/packages/rocketchat-lib/client/UserDeleted.js
@@ -0,0 +1,7 @@
+Meteor.startup(function() {
+ RocketChat.Notifications.onLogged('Users:Deleted', ({ userId }) =>
+ ChatMessage.remove({
+ 'u._id': userId
+ })
+ );
+});
diff --git a/packages/rocketchat-lib/package.js b/packages/rocketchat-lib/package.js
index e1d0cec3e59..8c0bc32f877 100644
--- a/packages/rocketchat-lib/package.js
+++ b/packages/rocketchat-lib/package.js
@@ -87,6 +87,7 @@ Package.onUse(function(api) {
api.addFiles('server/functions/checkUsernameAvailability.js', 'server');
api.addFiles('server/functions/checkEmailAvailability.js', 'server');
api.addFiles('server/functions/createRoom.js', 'server');
+ api.addFiles('server/functions/cleanRoomHistory.js', 'server');
api.addFiles('server/functions/deleteMessage.js', 'server');
api.addFiles('server/functions/deleteUser.js', 'server');
api.addFiles('server/functions/getFullUserData.js', 'server');
@@ -153,7 +154,6 @@ Package.onUse(function(api) {
api.addFiles('server/methods/blockUser.js', 'server');
api.addFiles('server/methods/checkRegistrationSecretURL.js', 'server');
api.addFiles('server/methods/checkUsernameAvailability.js', 'server');
- api.addFiles('server/methods/cleanChannelHistory.js', 'server');
api.addFiles('server/methods/cleanRoomHistory.js', 'server');
api.addFiles('server/methods/createChannel.js', 'server');
api.addFiles('server/methods/createToken.js', 'server');
@@ -204,6 +204,7 @@ Package.onUse(function(api) {
// CLIENT LIB
api.addFiles('client/Notifications.js', 'client');
api.addFiles('client/OAuthProxy.js', 'client');
+ api.addFiles('client/UserDeleted.js', 'client');
api.addFiles('client/lib/RestApiClient.js', 'client');
api.addFiles('client/lib/TabBar.js', 'client');
api.addFiles('client/lib/RocketChatTabBar.js', 'client');
diff --git a/packages/rocketchat-lib/server/functions/cleanRoomHistory.js b/packages/rocketchat-lib/server/functions/cleanRoomHistory.js
new file mode 100644
index 00000000000..323651f5f3a
--- /dev/null
+++ b/packages/rocketchat-lib/server/functions/cleanRoomHistory.js
@@ -0,0 +1,43 @@
+RocketChat.cleanRoomHistory = function({ rid, latest = new Date(), oldest = new Date('0001-01-01T00:00:00Z'), inclusive = true, limit = 0, excludePinned = true, filesOnly = false, fromUsers = [] }) {
+ const gt = inclusive ? '$gte' : '$gt';
+ const lt = inclusive ? '$lte' : '$lt';
+
+ const ts = { [gt]: oldest, [lt]: latest };
+
+ const text = `_${ TAPi18n.__('File_removed_by_prune') }_`;
+
+ let fileCount = 0;
+ RocketChat.models.Messages.findFilesByRoomIdPinnedTimestampAndUsers(
+ rid,
+ excludePinned,
+ ts,
+ fromUsers,
+ { fields: { 'file._id': 1, pinned: 1 }, limit }
+ ).forEach(document => {
+ FileUpload.getStore('Uploads').deleteById(document.file._id);
+ fileCount++;
+ if (filesOnly) {
+ RocketChat.models.Messages.update({ _id: document._id }, { $unset: { file: 1 }, $set: { attachments: [{ color: '#FD745E', text }] } });
+ }
+ });
+ if (filesOnly) {
+ return fileCount;
+ }
+
+ let count = 0;
+ if (limit) {
+ count = RocketChat.models.Messages.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ts, limit, fromUsers);
+ } else {
+ count = RocketChat.models.Messages.removeByIdPinnedTimestampAndUsers(rid, excludePinned, ts, fromUsers);
+ }
+
+ if (count) {
+ RocketChat.Notifications.notifyRoom(rid, 'deleteMessageBulk', {
+ rid,
+ excludePinned,
+ ts,
+ users: fromUsers
+ });
+ }
+ return count;
+};
diff --git a/packages/rocketchat-lib/server/functions/deleteUser.js b/packages/rocketchat-lib/server/functions/deleteUser.js
index 5a65b0fae5f..88014d91dc0 100644
--- a/packages/rocketchat-lib/server/functions/deleteUser.js
+++ b/packages/rocketchat-lib/server/functions/deleteUser.js
@@ -1,16 +1,21 @@
RocketChat.deleteUser = function(userId) {
- const user = RocketChat.models.Users.findOneById(userId);
+ const user = RocketChat.models.Users.findOneById(userId, {
+ fields: { username: 1, avatarOrigin: 1 }
+ });
// Users without username can't do anything, so there is nothing to remove
if (user.username != null) {
const messageErasureType = RocketChat.settings.get('Message_ErasureType');
-
switch (messageErasureType) {
- case 'Delete' :
+ case 'Delete':
+ const store = FileUpload.getStore('Uploads');
+ RocketChat.models.Messages.findFilesByUserId(userId).forEach(function({ file }) {
+ store.deleteById(file._id);
+ });
RocketChat.models.Messages.removeByUserId(userId);
break;
- case 'Unlink' :
- const rocketCat = RocketChat.models.Users.findById('rocket.cat').fetch()[0];
+ case 'Unlink':
+ const rocketCat = RocketChat.models.Users.findOneById('rocket.cat');
const nameAlias = TAPi18n.__('Removed_User');
RocketChat.models.Messages.unlinkUserId(userId, rocketCat._id, rocketCat.username, nameAlias);
break;
@@ -20,10 +25,12 @@ RocketChat.deleteUser = function(userId) {
const room = RocketChat.models.Rooms.findOneById(subscription.rid);
if (room) {
if (room.t !== 'c' && RocketChat.models.Subscriptions.findByRoomId(room._id).count() === 1) {
+ RocketChat.models.Messages.removeFilesByRoomId(subscription.rid);
RocketChat.models.Rooms.removeById(subscription.rid); // Remove non-channel rooms with only 1 user (the one being deleted)
}
if (room.t === 'd') {
RocketChat.models.Subscriptions.removeByRoomId(subscription.rid);
+ RocketChat.models.Messages.removeFilesByRoomId(subscription.rid);
RocketChat.models.Messages.removeByRoomId(subscription.rid);
}
}
@@ -38,6 +45,7 @@ RocketChat.deleteUser = function(userId) {
}
RocketChat.models.Integrations.disableByUserId(userId); // Disables all the integrations which rely on the user being deleted.
+ RocketChat.Notifications.notifyLogged('Users:Deleted', { userId });
}
RocketChat.models.Users.removeById(userId); // Remove user from users database
diff --git a/packages/rocketchat-lib/server/methods/cleanChannelHistory.js b/packages/rocketchat-lib/server/methods/cleanChannelHistory.js
deleted file mode 100644
index caeae90e974..00000000000
--- a/packages/rocketchat-lib/server/methods/cleanChannelHistory.js
+++ /dev/null
@@ -1,10 +0,0 @@
-Meteor.methods({
- /**
- DEPRECATED
- // TODO: Remove this after three versions have been released. That means at 0.67 this should be gone.
- */
- cleanChannelHistory({ roomId, latest, oldest, inclusive }) {
- console.warn('The method "cleanChannelHistory" is deprecated and will be removed after version 0.67, please use "cleanRoomHistory" instead');
- Meteor.call('cleanRoomHistory', { roomId, latest, oldest, inclusive });
- }
-});
diff --git a/packages/rocketchat-lib/server/methods/cleanRoomHistory.js b/packages/rocketchat-lib/server/methods/cleanRoomHistory.js
index bd612fd989f..6a330bdd4a4 100644
--- a/packages/rocketchat-lib/server/methods/cleanRoomHistory.js
+++ b/packages/rocketchat-lib/server/methods/cleanRoomHistory.js
@@ -1,34 +1,26 @@
+/* globals FileUpload */
+
Meteor.methods({
- cleanRoomHistory({ roomId, latest, oldest, inclusive }) {
+ cleanRoomHistory({ roomId, latest, oldest, inclusive = true, limit, excludePinned = false, filesOnly = false, fromUsers = [] }) {
check(roomId, String);
check(latest, Date);
check(oldest, Date);
check(inclusive, Boolean);
+ check(limit, Match.Maybe(Number));
+ check(excludePinned, Match.Maybe(Boolean));
+ check(filesOnly, Match.Maybe(Boolean));
+ check(fromUsers, Match.Maybe([String]));
+
+ const userId = Meteor.userId();
- if (!Meteor.userId()) {
+ if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cleanRoomHistory' });
}
- if (!RocketChat.authz.hasPermission(Meteor.userId(), 'clean-channel-history')) {
+ if (!RocketChat.authz.hasPermission(userId, 'clean-channel-history', roomId)) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' });
}
- if (inclusive) {
- RocketChat.models.Messages.remove({
- rid: roomId,
- ts: {
- $gte: oldest,
- $lte: latest
- }
- });
- } else {
- RocketChat.models.Messages.remove({
- rid: roomId,
- ts: {
- $gt: oldest,
- $lt: latest
- }
- });
- }
+ return RocketChat.cleanRoomHistory({ rid: roomId, latest, oldest, inclusive, limit, excludePinned, filesOnly, fromUsers });
}
});
diff --git a/packages/rocketchat-lib/server/models/Messages.js b/packages/rocketchat-lib/server/models/Messages.js
index 763f62a3b9f..f9086c61e74 100644
--- a/packages/rocketchat-lib/server/models/Messages.js
+++ b/packages/rocketchat-lib/server/models/Messages.js
@@ -42,6 +42,31 @@ RocketChat.models.Messages = new class extends RocketChat.models._Base {
return this.find(query, options);
}
+ findFilesByUserId(userId, options = {}) {
+ const query = {
+ 'u._id': userId,
+ 'file._id': { $exists: true }
+ };
+ return this.find(query, { fields: { 'file._id': 1 }, ...options });
+ }
+
+ findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ts, users = [], options = {}) {
+ const query = {
+ rid,
+ ts,
+ 'file._id': { $exists: true }
+ };
+
+ if (excludePinned) {
+ query.pinned = { $ne: true };
+ }
+
+ if (users.length) {
+ query['u.username'] = { $in: users };
+ }
+
+ return this.find(query, { fields: { 'file._id': 1 }, ...options });
+ }
findVisibleByMentionAndRoomId(username, rid, options) {
const query = {
_hidden: { $ne: true },
@@ -695,12 +720,70 @@ RocketChat.models.Messages = new class extends RocketChat.models._Base {
return this.remove(query);
}
+ removeByIdPinnedTimestampAndUsers(rid, pinned, ts, users = []) {
+ const query = {
+ rid,
+ ts
+ };
+
+ if (pinned) {
+ query.pinned = { $ne: true };
+ }
+
+ if (users.length) {
+ query['u.username'] = { $in: users };
+ }
+
+ return this.remove(query);
+ }
+
+ removeByIdPinnedTimestampLimitAndUsers(rid, pinned, ts, limit, users = []) {
+ const query = {
+ rid,
+ ts
+ };
+
+ if (pinned) {
+ query.pinned = { $ne: true };
+ }
+
+ if (users.length) {
+ query['u.username'] = { $in: users };
+ }
+
+ const messagesToDelete = RocketChat.models.Messages.find(query, {
+ fields: {
+ _id: 1
+ },
+ limit
+ }).map(({ _id }) => _id);
+
+ return this.remove({
+ _id: {
+ $in: messagesToDelete
+ }
+ });
+ }
+
removeByUserId(userId) {
const query = {'u._id': userId};
return this.remove(query);
}
+ removeFilesByRoomId(roomId) {
+ this.find({
+ rid: roomId,
+ 'file._id': {
+ $exists: true
+ }
+ }, {
+ fields: {
+ 'file._id': 1
+ }
+ }).fetch().forEach(document => FileUpload.getStore('Uploads').deleteById(document.file._id));
+ }
+
getMessageByFileId(fileID) {
return this.findOne({ 'file._id': fileID });
}
diff --git a/packages/rocketchat-lib/server/models/Rooms.js b/packages/rocketchat-lib/server/models/Rooms.js
index b714069c78f..048df317bf1 100644
--- a/packages/rocketchat-lib/server/models/Rooms.js
+++ b/packages/rocketchat-lib/server/models/Rooms.js
@@ -602,6 +602,73 @@ class ModelRooms extends RocketChat.models._Base {
return this.update(query, update);
}
+ saveRetentionEnabledById(_id, value) {
+ const query = {_id};
+
+ const update = {};
+
+ if (value == null) {
+ update.$unset = { 'retention.enabled': true };
+ } else {
+ update.$set = { 'retention.enabled': !!value };
+ }
+
+ return this.update(query, update);
+ }
+
+ saveRetentionMaxAgeById(_id, value) {
+ const query = {_id};
+
+ value = Number(value);
+ if (!value) {
+ value = 30;
+ }
+
+ const update = {
+ $set: {
+ 'retention.maxAge': value
+ }
+ };
+
+ return this.update(query, update);
+ }
+
+ saveRetentionExcludePinnedById(_id, value) {
+ const query = {_id};
+
+ const update = {
+ $set: {
+ 'retention.excludePinned': value === true
+ }
+ };
+
+ return this.update(query, update);
+ }
+
+ saveRetentionFilesOnlyById(_id, value) {
+ const query = {_id};
+
+ const update = {
+ $set: {
+ 'retention.filesOnly': value === true
+ }
+ };
+
+ return this.update(query, update);
+ }
+
+ saveRetentionOverrideGlobalById(_id, value) {
+ const query = { _id };
+
+ const update = {
+ $set: {
+ 'retention.overrideGlobal': value === true
+ }
+ };
+
+ return this.update(query, update);
+ }
+
setTopicAndTagsById(_id, topic, tags) {
const setData = {};
const unsetData = {};
diff --git a/packages/rocketchat-retention-policy/README.md b/packages/rocketchat-retention-policy/README.md
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/rocketchat-retention-policy/package.js b/packages/rocketchat-retention-policy/package.js
new file mode 100644
index 00000000000..7ced720f7ae
--- /dev/null
+++ b/packages/rocketchat-retention-policy/package.js
@@ -0,0 +1,25 @@
+Package.describe({
+ name: 'rocketchat:retention-policy',
+ version: '0.0.1',
+ // Brief, one-line summary of the package.
+ summary: '',
+ // URL to the Git repository containing the source code for this package.
+ git: '',
+ // By default, Meteor will default to using README.md for documentation.
+ // To avoid submitting documentation, set this field to null.
+ documentation: 'README.md'
+});
+
+Package.onUse(function(api) {
+ api.use([
+ 'mongo',
+ 'ecmascript',
+ 'templating',
+ 'rocketchat:lib'
+ ]);
+
+ api.addFiles([
+ 'server/startup/settings.js',
+ 'server/cronPruneMessages.js'
+ ], 'server');
+});
diff --git a/packages/rocketchat-retention-policy/server/cronPruneMessages.js b/packages/rocketchat-retention-policy/server/cronPruneMessages.js
new file mode 100644
index 00000000000..1ee35ddebde
--- /dev/null
+++ b/packages/rocketchat-retention-policy/server/cronPruneMessages.js
@@ -0,0 +1,123 @@
+/* globals SyncedCron */
+
+let types = [];
+
+const oldest = new Date('0001-01-01T00:00:00Z');
+
+let lastPrune = oldest;
+
+const maxTimes = {
+ c: 0,
+ p: 0,
+ d: 0
+};
+const toDays = 1000 * 60 * 60 * 24;
+const gracePeriod = 5000;
+function job() {
+ const now = new Date();
+ const filesOnly = RocketChat.settings.get('RetentionPolicy_FilesOnly');
+ const excludePinned = RocketChat.settings.get('RetentionPolicy_ExcludePinned');
+
+ // get all rooms with default values
+ types.forEach(type => {
+ const maxAge = maxTimes[type] || 0;
+ const latest = new Date(now.getTime() - maxAge * toDays);
+
+ RocketChat.models.Rooms.find({
+ t: type,
+ _updatedAt: { $gte: lastPrune },
+ $or: [{'retention.enabled': { $eq: true } }, { 'retention.enabled': { $exists: false } }],
+ 'retention.overrideGlobal': { $ne: true }
+ }).forEach(({ _id: rid }) => {
+ RocketChat.cleanRoomHistory({ rid, latest, oldest, filesOnly, excludePinned });
+ });
+ });
+
+ RocketChat.models.Rooms.find({
+ 'retention.enabled': { $eq: true },
+ 'retention.overrideGlobal': { $eq: true },
+ 'retention.maxAge': { $gte: 0 },
+ _updatedAt: { $gte: lastPrune }
+ }).forEach(room => {
+ const { maxAge = 30, filesOnly, excludePinned } = room.retention;
+ const latest = new Date(now.getTime() - maxAge * toDays);
+ RocketChat.cleanRoomHistory({ rid: room._id, latest, oldest, filesOnly, excludePinned });
+ });
+ lastPrune = new Date(now.getTime() - gracePeriod);
+}
+
+function getSchedule(precision) {
+ switch (precision) {
+ case '0':
+ return '0 */30 * * * *';
+ case '1':
+ return '0 0 * * * *';
+ case '2':
+ return '0 0 */6 * * *';
+ case '3':
+ return '0 0 0 * * *';
+ }
+}
+
+const pruneCronName = 'Prune old messages by retention policy';
+
+function deployCron(precision) {
+ const schedule = parser => parser.cron(getSchedule(precision), true);
+
+ SyncedCron.remove(pruneCronName);
+ SyncedCron.add({
+ name: pruneCronName,
+ schedule,
+ job
+ });
+}
+
+function reloadPolicy() {
+ types = [];
+
+ if (RocketChat.settings.get('RetentionPolicy_Enabled')) {
+ if (RocketChat.settings.get('RetentionPolicy_AppliesToChannels')) {
+ types.push('c');
+ }
+
+ if (RocketChat.settings.get('RetentionPolicy_AppliesToGroups')) {
+ types.push('p');
+ }
+
+ if (RocketChat.settings.get('RetentionPolicy_AppliesToDMs')) {
+ types.push('d');
+ }
+
+ maxTimes.c = RocketChat.settings.get('RetentionPolicy_MaxAge_Channels');
+ maxTimes.p = RocketChat.settings.get('RetentionPolicy_MaxAge_Groups');
+ maxTimes.d = RocketChat.settings.get('RetentionPolicy_MaxAge_DMs');
+
+ return deployCron(RocketChat.settings.get('RetentionPolicy_Precision'));
+ }
+ return SyncedCron.remove(pruneCronName);
+}
+
+Meteor.startup(function() {
+ Meteor.defer(function() {
+ RocketChat.models.Settings.find({
+ _id: {
+ $in: [
+ 'RetentionPolicy_Enabled',
+ 'RetentionPolicy_Precision',
+ 'RetentionPolicy_AppliesToChannels',
+ 'RetentionPolicy_AppliesToGroups',
+ 'RetentionPolicy_AppliesToDMs',
+ 'RetentionPolicy_MaxAge_Channels',
+ 'RetentionPolicy_MaxAge_Groups',
+ 'RetentionPolicy_MaxAge_DMs'
+ ]
+ }
+ }).observe({
+ changed() {
+ reloadPolicy();
+ }
+ });
+
+ reloadPolicy();
+ });
+});
diff --git a/packages/rocketchat-retention-policy/server/startup/settings.js b/packages/rocketchat-retention-policy/server/startup/settings.js
new file mode 100644
index 00000000000..a9d8bf4894e
--- /dev/null
+++ b/packages/rocketchat-retention-policy/server/startup/settings.js
@@ -0,0 +1,107 @@
+RocketChat.settings.addGroup('RetentionPolicy', function() {
+
+ this.add('RetentionPolicy_Enabled', false, {
+ type: 'boolean',
+ public: true,
+ i18nLabel: 'RetentionPolicy_Enabled',
+ alert: 'Watch out! Tweaking these settings without utmost care can destroy all message history. Please read the documentation before turning the feature on at rocket.chat/docs/administrator-guides/retention-policies/'
+ });
+
+ this.add('RetentionPolicy_Precision', '0', {
+ type: 'select',
+ values: [
+ {
+ key: '0',
+ i18nLabel: 'every_30_minutes'
+ }, {
+ key: '1',
+ i18nLabel: 'every_hour'
+ }, {
+ key: '2',
+ i18nLabel: 'every_six_hours'
+ }, {
+ key: '3',
+ i18nLabel: 'every_day'
+ }
+ ],
+ public: true,
+ i18nLabel: 'RetentionPolicy_Precision',
+ i18nDescription: 'RetentionPolicy_Precision_Description',
+ enableQuery: {
+ _id: 'RetentionPolicy_Enabled',
+ value: true
+ }
+ });
+
+ this.section('Global Policy', function() {
+ const globalQuery = {
+ _id: 'RetentionPolicy_Enabled',
+ value: true
+ };
+
+ this.add('RetentionPolicy_AppliesToChannels', false, {
+ type: 'boolean',
+ public: true,
+ i18nLabel: 'RetentionPolicy_AppliesToChannels',
+ enableQuery: globalQuery
+ });
+ this.add('RetentionPolicy_MaxAge_Channels', 30, {
+ type: 'int',
+ public: true,
+ i18nLabel: 'RetentionPolicy_MaxAge_Channels',
+ i18nDescription: 'RetentionPolicy_MaxAge_Description',
+ enableQuery: [{
+ _id: 'RetentionPolicy_AppliesToChannels',
+ value: true
+ }, globalQuery]
+ });
+
+ this.add('RetentionPolicy_AppliesToGroups', false, {
+ type: 'boolean',
+ public: true,
+ i18nLabel: 'RetentionPolicy_AppliesToGroups',
+ enableQuery: globalQuery
+ });
+ this.add('RetentionPolicy_MaxAge_Groups', 30, {
+ type: 'int',
+ public: true,
+ i18nLabel: 'RetentionPolicy_MaxAge_Groups',
+ i18nDescription: 'RetentionPolicy_MaxAge_Description',
+ enableQuery: [{
+ _id: 'RetentionPolicy_AppliesToGroups',
+ value: true
+ }, globalQuery]
+ });
+
+ this.add('RetentionPolicy_AppliesToDMs', false, {
+ type: 'boolean',
+ public: true,
+ i18nLabel: 'RetentionPolicy_AppliesToDMs',
+ enableQuery: globalQuery
+ });
+ this.add('RetentionPolicy_MaxAge_DMs', 30, {
+ type: 'int',
+ public: true,
+ i18nLabel: 'RetentionPolicy_MaxAge_DMs',
+ i18nDescription: 'RetentionPolicy_MaxAge_Description',
+ enableQuery: [{
+ _id: 'RetentionPolicy_AppliesToDMs',
+ value: true
+ }, globalQuery]
+ });
+
+ this.add('RetentionPolicy_ExcludePinned', false, {
+ type: 'boolean',
+ public: true,
+ i18nLabel: 'RetentionPolicy_ExcludePinned',
+ enableQuery: globalQuery
+ });
+ this.add('RetentionPolicy_FilesOnly', false, {
+ type: 'boolean',
+ public: true,
+ i18nLabel: 'RetentionPolicy_FilesOnly',
+ i18nDescription: 'RetentionPolicy_FilesOnly_Description',
+ enableQuery: globalQuery
+ });
+ });
+});
diff --git a/packages/rocketchat-theme/client/imports/general/base_old.css b/packages/rocketchat-theme/client/imports/general/base_old.css
index 7081fb15ee4..06795937eb3 100644
--- a/packages/rocketchat-theme/client/imports/general/base_old.css
+++ b/packages/rocketchat-theme/client/imports/general/base_old.css
@@ -2822,6 +2822,14 @@
margin-top: 12px;
text-align: center;
+
+ & .start__purge-warning {
+ padding: 0.5rem;
+ margin-bottom: 0.5rem;
+ margin-top: -33px;
+ border-width: 1px 0 0 0;
+ background: linear-gradient(to bottom, var(--rc-color-alert-message-warning-background) 0%, transparent 100%);
+ }
}
& .new-message {
diff --git a/packages/rocketchat-ui-clean-history/README.md b/packages/rocketchat-ui-clean-history/README.md
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/rocketchat-ui-clean-history/client/lib/startup.js b/packages/rocketchat-ui-clean-history/client/lib/startup.js
new file mode 100644
index 00000000000..0a151f40a01
--- /dev/null
+++ b/packages/rocketchat-ui-clean-history/client/lib/startup.js
@@ -0,0 +1,12 @@
+Meteor.startup(() => {
+ RocketChat.TabBar.addButton({
+ groups: ['channel', 'group', 'direct'],
+ id: 'clean-history',
+ anonymous: true,
+ i18nTitle: 'Prune_Messages',
+ icon: 'trash',
+ template: 'cleanHistory',
+ order: 250,
+ condition: () => RocketChat.authz.hasAllPermission('clean-channel-history', Session.get('openedRoom'))
+ });
+});
diff --git a/packages/rocketchat-ui-clean-history/client/views/cleanHistory.html b/packages/rocketchat-ui-clean-history/client/views/cleanHistory.html
new file mode 100644
index 00000000000..b9063705f88
--- /dev/null
+++ b/packages/rocketchat-ui-clean-history/client/views/cleanHistory.html
@@ -0,0 +1,142 @@
+
+ {{#unless busy}}
+ + {{#unless filesOnly}} + {{_ "Pruning_messages"}} + {{else}} + {{_ "Pruning_files"}} + {{/unless}} +
+{{_ "Prune_finished"}}
+