[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.

![bzzzzzzzz](https://user-images.githubusercontent.com/39674991/41799087-56d1dea0-7670-11e8-94c0-bc534b1f832d.png)
pull/11519/head
フィンメーラ 7 years ago committed by Guilherme Gazzo
parent 5244bd51e7
commit 829c5d1bfb
  1. 2
      .meteor/packages
  2. 2
      .meteor/versions
  3. 35
      packages/rocketchat-api/server/v1/channels.js
  4. 2
      packages/rocketchat-api/server/v1/rooms.js
  5. 3
      packages/rocketchat-authorization/server/startup.js
  6. 118
      packages/rocketchat-channel-settings/client/views/channelSettings.html
  7. 311
      packages/rocketchat-channel-settings/client/views/channelSettings.js
  8. 62
      packages/rocketchat-channel-settings/server/methods/saveRoomSettings.js
  9. 62
      packages/rocketchat-i18n/i18n/en.i18n.json
  10. 7
      packages/rocketchat-lib/client/UserDeleted.js
  11. 3
      packages/rocketchat-lib/package.js
  12. 43
      packages/rocketchat-lib/server/functions/cleanRoomHistory.js
  13. 18
      packages/rocketchat-lib/server/functions/deleteUser.js
  14. 10
      packages/rocketchat-lib/server/methods/cleanChannelHistory.js
  15. 32
      packages/rocketchat-lib/server/methods/cleanRoomHistory.js
  16. 83
      packages/rocketchat-lib/server/models/Messages.js
  17. 67
      packages/rocketchat-lib/server/models/Rooms.js
  18. 0
      packages/rocketchat-retention-policy/README.md
  19. 25
      packages/rocketchat-retention-policy/package.js
  20. 123
      packages/rocketchat-retention-policy/server/cronPruneMessages.js
  21. 107
      packages/rocketchat-retention-policy/server/startup/settings.js
  22. 8
      packages/rocketchat-theme/client/imports/general/base_old.css
  23. 0
      packages/rocketchat-ui-clean-history/README.md
  24. 12
      packages/rocketchat-ui-clean-history/client/lib/startup.js
  25. 142
      packages/rocketchat-ui-clean-history/client/views/cleanHistory.html
  26. 352
      packages/rocketchat-ui-clean-history/client/views/cleanHistory.js
  27. 52
      packages/rocketchat-ui-clean-history/client/views/stylesheets/cleanHistory.css
  28. 27
      packages/rocketchat-ui-clean-history/package.js
  29. 14
      packages/rocketchat-ui-master/public/icons.svg
  30. 19
      packages/rocketchat-ui/client/lib/RoomManager.js
  31. 4
      packages/rocketchat-ui/client/lib/tapi18n.js
  32. 19
      packages/rocketchat-ui/client/views/app/room.html
  33. 94
      packages/rocketchat-ui/client/views/app/room.js
  34. 23
      server/methods/eraseRoom.js
  35. 1
      server/publications/room.js
  36. 18
      tests/end-to-end/api/02-channels.js

@ -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

@ -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

@ -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 });

@ -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();

@ -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'] },

@ -117,12 +117,11 @@
</div>
{{/with}}
{{#with settings.reactWhenReadOnly}}
{{#if canView}}
<div class="rc-user-info__row rc-user-info__row--separator">
<div class="rc-switch-double">
<div class="rc-switch-double__label {{equal true value 'disabled'}}">
<div class="rc-switch-double__label {{equal false value 'disabled'}}">
{{_ "React_when_read_only"}}
<div class="rc-switch-double__description">
{{_ "React_when_read_only"}}
@ -136,7 +135,7 @@
</span>
</label>
</div>
<div class="rc-switch-double__label {{equal false value 'disabled'}}">
<div class="rc-switch-double__label {{equal true value 'disabled'}}">
{{_ "Disallow_reacting"}}
<div class="rc-switch-double__description">
{{_ "Disallow_reacting_Description"}}
@ -164,7 +163,7 @@
{{/if}}
{{/with}}
{{#with settings.sysMes}}
{{#with settings.sysMes}}
{{#if canView}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
@ -200,7 +199,7 @@
{{/if}}
{{/with}}
{{#with settings.joinCode}}
<div class="rc-user-info__row">
<div class="rc-user-info__row rc-user-info__row--separator">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ label}}{{equal default value '*'}}</div>
@ -211,6 +210,93 @@
</div>
</div>
{{/with}}
{{#if hasRetentionPermission}}
<div class="rc-user-info__config">
<div class="rc-user-info__config-header">
{{> icon block="rc-user-info__config-icon" icon="trash"}}
<span class="rc-user-info__config-label">{{_ "Prune"}}</span>
</div>
{{#with settings.retentionEnabled}}
<div class="rc-user-info__config-content">
<div class="rc-user-info__config-name">{{_ label}}:</div>
<div class="rc-user-info__config-value">
{{subValue value}} {{> icon block="rc-user-info__config-content-icon" icon="arrow-down"}}
</div>
</div>
{{/with}}
</div>
{{# if retentionEnabled settings.retentionEnabled.value.get }}
{{#with settings.retentionOverrideGlobal}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionOverrideGlobal" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{/if}}
{{# if settings.retentionOverrideGlobal.value.get }}
<div class="mail-messages__instructions mail-messages__instructions--warning" style="margin-bottom: 0;">
<div class="mail-messages__instructions-wrapper">
<div class="mail-messages__instructions-text">
<span>
{{{_ "RetentionPolicyRoom_ReadTheDocs"}}}
</span>
</div>
</div>
</div>
{{#with settings.retentionMaxAge}}
<div class="rc-user-info__row">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{retentionMaxAgeLabel label}}{{equal default value '*'}}</div>
<div class="rc-input__wrapper">
<input type="number" name="retentionMaxAge" value="{{value}}" class="rc-input__element js-input" disabled="{{./disabled}}"/>
</div>
</label>
</div>
</div>
{{/with}}
{{#with settings.retentionExcludePinned}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionExcludePinned" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{#with settings.retentionFilesOnly}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionFilesOnly" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{/if}}
{{/if}}
</div>
<div class="rc-user-info__row">
<div class="rc-user-info__flex rc-user-info__row rc-user-info__row--separator">
@ -269,6 +355,28 @@
<b>{{_ "Broadcast_channel"}}:</b> {{_ "Broadcast_channel_Description"}}
</label>
{{/if}}
{{#if hasPurge}}
<div class="mail-messages__instructions mail-messages__instructions--warning">
<div class="mail-messages__instructions-wrapper">
{{> icon block="mail-messages__instructions-icon" icon="warning-empty"}}
<div class="mail-messages__instructions-text">
{{#unless filesOnly}}
{{#unless excludePinned}}
{{_ "RetentionPolicy_RoomWarning" purgeTimeout}}
{{else}}
{{_ "RetentionPolicy_RoomWarning_Unpinned" purgeTimeout}}
{{/unless}}
{{else}}
{{#unless excludePinned}}
{{_ "RetentionPolicy_RoomWarning_FilesOnly" purgeTimeout}}
{{else}}
{{_ "RetentionPolicy_RoomWarning_UnpinnedFilesOnly" purgeTimeout}}
{{/unless}}
{{/unless}}
</div>
</div>
</div>
{{/if}}
{{/with}}
{{#each channelSettings}}
<div class="rc-user-info__row">

@ -1,6 +1,9 @@
/* globals popover */
import toastr from 'toastr';
import moment from 'moment';
import s from 'underscore.string';
import { call, erase, hide, leave, RocketChat, RoomSettingsEnum } from 'meteor/rocketchat:lib';
const common = {
canLeaveRoom() {
const { cl: canLeave, t: roomType } = Template.instance().room;
@ -26,6 +29,95 @@ const common = {
}
};
function roomFilesOnly(room) {
if (!room.retention) {
return;
}
if (room.retention && room.retention.overrideGlobal) {
return room.retention.filesOnly;
}
return RocketChat.settings.get('RetentionPolicy_FilesOnly');
}
function roomExcludePinned(room) {
if (!room || !room.retention) {
return;
}
if (room.retention && room.retention.overrideGlobal) {
return room.retention.excludePinned;
}
return RocketChat.settings.get('RetentionPolicy_ExcludePinned');
}
function roomHasGlobalPurge(room) {
if (!RocketChat.settings.get('RetentionPolicy_Enabled')) {
return false;
}
switch (room.t) {
case 'c':
return RocketChat.settings.get('RetentionPolicy_AppliesToChannels');
case 'p':
return RocketChat.settings.get('RetentionPolicy_AppliesToGroups');
case 'd':
return RocketChat.settings.get('RetentionPolicy_AppliesToDMs');
}
return false;
}
function roomHasPurge(room) {
if (!room || !RocketChat.settings.get('RetentionPolicy_Enabled')) {
return false;
}
if (room.retention && room.retention.enabled !== undefined) {
return room.retention.enabled;
}
return roomHasGlobalPurge(room);
}
function retentionEnabled({t: type}) {
switch (type) {
case 'c':
return RocketChat.settings.get('RetentionPolicy_AppliesToChannels');
case 'p':
return RocketChat.settings.get('RetentionPolicy_AppliesToGroups');
case 'd':
return RocketChat.settings.get('RetentionPolicy_AppliesToDMs');
}
return false;
}
function roomMaxAgeDefault(type) {
switch (type) {
case 'c':
return RocketChat.settings.get('RetentionPolicy_MaxAge_Channels');
case 'p':
return RocketChat.settings.get('RetentionPolicy_MaxAge_Groups');
case 'd':
return RocketChat.settings.get('RetentionPolicy_MaxAge_DMs');
default:
return 30; // days
}
}
function roomMaxAge(room) {
if (!room) {
return;
}
if (room.retention && room.retention.overrideGlobal) {
return room.retention.maxAge;
}
return roomMaxAgeDefault(room.t);
}
Template.channelSettingsEditing.events({
'input .js-input'(e) {
this.value.set(e.currentTarget.value);
@ -37,6 +129,44 @@ Template.channelSettingsEditing.events({
const { settings } = t;
Object.keys(settings).forEach(key => settings[key].value.set(settings[key].default.get()));
},
'click .rc-user-info__config-value'(e) {
const options = [{
id: 'prune_default',
name: 'prune_value',
label: 'Default',
value: 'default'
},
{
id: 'prune_enabled',
name: 'prune_value',
label: 'Enabled',
value: 'enabled'
},
{
id: 'prune_disabled',
name: 'prune_value',
label: 'Disabled',
value: 'disabled'
}];
const value = this.value.get() ? 'enabled' : this.value.get() === false ? 'disabled' : 'default';
const config = {
popoverClass: 'notifications-preferences',
template: 'pushNotificationsPopover',
data: {
change : (value) => {
const realValue = value === 'enabled' ? true : value === 'disabled' ? false : undefined;
return this.value.set(realValue);
},
value,
options
},
currentTarget: e.currentTarget,
offsetVertical: e.currentTarget.clientHeight + 10
};
popover.open(config);
},
async 'click .js-save'(e, t) {
const { settings } = t;
Object.keys(settings).forEach(async name => {
@ -95,7 +225,7 @@ Template.channelSettingsEditing.onCreated(function() {
name: value
});
return toastr.success(TAPi18n.__('Room_name_changed_successfully'));
return toastr.success(t('Room_name_changed_successfully'));
});
}
},
@ -110,7 +240,7 @@ Template.channelSettingsEditing.onCreated(function() {
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.TOPIC, value).then(function() {
toastr.success(TAPi18n.__('Room_topic_changed_successfully'));
toastr.success(t('Room_topic_changed_successfully'));
return RocketChat.callbacks.run('roomTopicChanged', room);
});
}
@ -129,7 +259,7 @@ Template.channelSettingsEditing.onCreated(function() {
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.ANNOUNCEMENT, value).then(() => {
toastr.success(TAPi18n.__('Room_announcement_changed_successfully'));
toastr.success(t('Room_announcement_changed_successfully'));
return RocketChat.callbacks.run('roomAnnouncementChanged', room);
});
}
@ -145,7 +275,7 @@ Template.channelSettingsEditing.onCreated(function() {
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.DESCRIPTION, value).then(function() {
return toastr.success(TAPi18n.__('Room_description_changed_successfully'));
return toastr.success(t('Room_description_changed_successfully'));
});
}
},
@ -187,7 +317,7 @@ Template.channelSettingsEditing.onCreated(function() {
value = value ? 'p' : 'c';
RocketChat.callbacks.run('roomTypeChanged', room);
return call('saveRoomSettings', room._id, 'roomType', value).then(() => {
return toastr.success(TAPi18n.__('Room_type_changed_successfully'));
return toastr.success(t('Room_type_changed_successfully'));
});
};
if (room['default']) {
@ -228,7 +358,7 @@ Template.channelSettingsEditing.onCreated(function() {
return !room.broadcast && RocketChat.authz.hasAllPermission('set-readonly', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.READ_ONLY, value).then(() => toastr.success(TAPi18n.__('Read_only_changed_successfully')));
return call('saveRoomSettings', room._id, RoomSettingsEnum.READ_ONLY, value).then(() => toastr.success(t('Read_only_changed_successfully')));
}
},
reactWhenReadOnly: {
@ -244,7 +374,7 @@ Template.channelSettingsEditing.onCreated(function() {
},
save(value) {
return call('saveRoomSettings', room._id, 'reactWhenReadOnly', value).then(() => {
toastr.success(TAPi18n.__('React_when_read_only_changed_successfully'));
toastr.success(t('React_when_read_only_changed_successfully'));
});
}
},
@ -269,7 +399,7 @@ Template.channelSettingsEditing.onCreated(function() {
return call('saveRoomSettings', room._id, 'systemMessages', value).then(
() => {
toastr.success(
TAPi18n.__('System_messages_setting_changed_successfully')
t('System_messages_setting_changed_successfully')
);
}
);
@ -372,17 +502,134 @@ Template.channelSettingsEditing.onCreated(function() {
},
save(value) {
return call('saveRoomSettings', room._id, 'joinCode', value).then(function() {
toastr.success(TAPi18n.__('Room_password_changed_successfully'));
toastr.success(t('Room_password_changed_successfully'));
return RocketChat.callbacks.run('roomCodeChanged', room);
});
}
},
retentionEnabled: {
type: 'boolean',
label: 'RetentionPolicyRoom_Enabled',
processing: new ReactiveVar(false),
getValue() {
const { room = {} } = Template.instance() || {};
return room.retention && room.retention.enabled;
},
canView() {
return true;
},
canEdit() {
return RocketChat.authz.hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionEnabled', value).then(() => toastr.success(t('Retention_setting_changed_successfully')));
}
},
retentionOverrideGlobal: {
type: 'boolean',
label: 'RetentionPolicyRoom_OverrideGlobal',
isToggle: true,
processing: new ReactiveVar(false),
getValue() {
return Template.instance().room.retention && Template.instance().room.retention.overrideGlobal;
},
canView() {
return true;
},
canEdit() {
return RocketChat.authz.hasAllPermission('edit-privileged-setting', room._id);
},
disabled() {
return !RocketChat.authz.hasAllPermission('edit-privileged-setting', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionOverrideGlobal', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully')
);
}
);
}
},
retentionMaxAge: {
type: 'number',
label: 'RetentionPolicyRoom_MaxAge',
processing: new ReactiveVar(false),
getValue() {
const { room } = Template.instance();
return Math.min(roomMaxAge(room), roomMaxAgeDefault(room.t));
},
canView() {
return true;
},
canEdit() {
return RocketChat.authz.hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionMaxAge', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully')
);
}
);
}
},
retentionExcludePinned: {
type: 'boolean',
label: 'RetentionPolicyRoom_ExcludePinned',
isToggle: true,
processing: new ReactiveVar(false),
getValue() {
return Template.instance().room.retention && Template.instance().room.retention.excludePinned;
},
canView() {
return true;
},
canEdit() {
return RocketChat.authz.hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionExcludePinned', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully')
);
}
);
}
},
retentionFilesOnly: {
type: 'boolean',
label: 'RetentionPolicyRoom_FilesOnly',
isToggle: true,
processing: new ReactiveVar(false),
getValue() {
return Template.instance().room.retention && Template.instance().room.retention.filesOnly;
},
canView() {
return true;
},
canEdit() {
return RocketChat.authz.hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionFilesOnly', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully')
);
}
);
}
}
};
Object.keys(this.settings).forEach(key => {
const setting = this.settings[key];
const def = setting.getValue ? setting.getValue(this.room) : this.room[key];
setting.default = new ReactiveVar(def || false);
setting.value = new ReactiveVar(def || false);
const def = setting.getValue ? setting.getValue(this.room) : (this.room[key] || false);
setting.default = new ReactiveVar(def);
setting.value = new ReactiveVar(def);
});
});
@ -434,6 +681,27 @@ Template.channelSettingsEditing.helpers({
},
unscape(value) {
return s.unescapeHTML(value);
},
hasRetentionPermission() {
const { room } = Template.instance();
return RocketChat.settings.get('RetentionPolicy_Enabled') && RocketChat.authz.hasAllPermission('edit-room-retention-policy', room._id);
},
subValue(value) {
if (value === undefined) {
const text = t(retentionEnabled(Template.instance().room) ? 'enabled' : 'disabled');
const _default = t('default');
return `${ text } (${ _default })`;
}
return t(value ? 'enabled' : 'disabled');
},
retentionEnabled(value) {
const { room } = Template.instance();
return value || value === undefined && retentionEnabled(room);
},
retentionMaxAgeLabel(label) {
const { room } = Template.instance();
return TAPi18n.__(label, { max: roomMaxAgeDefault(room.t) });
}
});
@ -518,5 +786,24 @@ Template.channelSettingsInfo.helpers({
default:
return null;
}
},
hasPurge() {
return roomHasPurge(Template.instance().room);
},
filesOnly() {
return roomFilesOnly(Template.instance().room);
},
excludePinned() {
return roomExcludePinned(Template.instance().room);
},
purgeTimeout() {
moment.relativeTimeThreshold('s', 60);
moment.relativeTimeThreshold('ss', 0);
moment.relativeTimeThreshold('m', 60);
moment.relativeTimeThreshold('h', 24);
moment.relativeTimeThreshold('d', 31);
moment.relativeTimeThreshold('M', 12);
return moment.duration(roomMaxAge(Template.instance().room) * 1000 * 60 * 60 * 24).humanize();
}
});

@ -1,7 +1,9 @@
const fields = ['roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions'];
const fields = ['roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionOverrideGlobal'];
Meteor.methods({
saveRoomSettings(rid, settings, value) {
if (!Meteor.userId()) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
'function': 'RocketChat.saveRoomName'
});
@ -24,7 +26,7 @@ Meteor.methods({
});
}
if (!RocketChat.authz.hasPermission(Meteor.userId(), 'edit-room', rid)) {
if (!RocketChat.authz.hasPermission(userId, 'edit-room', rid)) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room is not allowed', {
method: 'saveRoomSettings',
action: 'Editing_room'
@ -48,26 +50,58 @@ Meteor.methods({
const user = Meteor.user();
// validations
Object.keys(settings).forEach(setting => {
const value = settings[setting];
if (settings === 'default' && !RocketChat.authz.hasPermission(this.userId, 'view-room-administration')) {
if (settings === 'default' && !RocketChat.authz.hasPermission(userId, 'view-room-administration')) {
throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', {
method: 'saveRoomSettings',
action: 'Viewing_room_administration'
});
}
if (setting === 'roomType' && value !== room.t && value === 'c' && !RocketChat.authz.hasPermission(this.userId, 'create-c')) {
if (setting === 'roomType' && value !== room.t && value === 'c' && !RocketChat.authz.hasPermission(userId, 'create-c')) {
throw new Meteor.Error('error-action-not-allowed', 'Changing a private group to a public channel is not allowed', {
method: 'saveRoomSettings',
action: 'Change_Room_Type'
});
}
if (setting === 'roomType' && value !== room.t && value === 'p' && !RocketChat.authz.hasPermission(this.userId, 'create-p')) {
if (setting === 'roomType' && value !== room.t && value === 'p' && !RocketChat.authz.hasPermission(userId, 'create-p')) {
throw new Meteor.Error('error-action-not-allowed', 'Changing a public channel to a private room is not allowed', {
method: 'saveRoomSettings',
action: 'Change_Room_Type'
});
}
if (setting === 'retentionEnabled' && !RocketChat.authz.hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.enabled) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', {
method: 'saveRoomSettings',
action: 'Editing_room'
});
}
if (setting === 'retentionMaxAge' && !RocketChat.authz.hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.maxAge) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', {
method: 'saveRoomSettings',
action: 'Editing_room'
});
}
if (setting === 'retentionExcludePinned' && !RocketChat.authz.hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.excludePinned) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', {
method: 'saveRoomSettings',
action: 'Editing_room'
});
}
if (setting === 'retentionFilesOnly' && !RocketChat.authz.hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.filesOnly) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', {
method: 'saveRoomSettings',
action: 'Editing_room'
});
}
if (setting === 'retentionOverrideGlobal') {
delete settings.retentionMaxAge;
delete settings.retentionExcludePinned;
delete settings.retentionFilesOnly;
}
});
Object.keys(settings).forEach(setting => {
@ -134,6 +168,22 @@ Meteor.methods({
break;
case 'default':
RocketChat.models.Rooms.saveDefaultById(rid, value);
break;
case 'retentionEnabled':
RocketChat.models.Rooms.saveRetentionEnabledById(rid, value);
break;
case 'retentionMaxAge':
RocketChat.models.Rooms.saveRetentionMaxAgeById(rid, value);
break;
case 'retentionExcludePinned':
RocketChat.models.Rooms.saveRetentionExcludePinnedById(rid, value);
break;
case 'retentionFilesOnly':
RocketChat.models.Rooms.saveRetentionFilesOnlyById(rid, value);
break;
case 'retentionOverrideGlobal':
RocketChat.models.Rooms.saveRetentionOverrideGlobalById(rid, value);
break;
}
});

@ -550,6 +550,7 @@
"Conversation": "Conversation",
"Conversation_closed": "Conversation closed: __comment__.",
"Conversation_finished_message": "Conversation Finished Message",
"conversation_with_s": "the conversation with %s",
"Convert_Ascii_Emojis": "Convert ASCII to Emoji",
"Copied": "Copied",
"Copy": "Copy",
@ -950,6 +951,8 @@
"edit-privileged-setting_description": "Permission to edit settings",
"edit-room": "Edit Room",
"edit-room_description": "Permission to edit a room's name, topic, type (private or public status) and status (active or archived)",
"edit-room-retention-policy": "Edit Room's Retention Policy",
"edit-room-retention-policy_description": "Permission to edit a room’s retention policy, to automatically delete messages in it",
"Edit_Custom_Field": "Edit Custom Field",
"Edit_Department": "Edit Department",
"Edit_previous_message": "`%s` - Edit previous message",
@ -1092,13 +1095,20 @@
"Esc_to": "Esc to",
"Event_Trigger": "Event Trigger",
"Event_Trigger_Description": "Select which type of event will trigger this Outgoing WebHook Integration",
"every_second": "Once every second",
"every_10_seconds": "Once every 10 seconds",
"every_minute": "Once every minute",
"every_5_minutes": "Once every 5 minutes",
"every_30_minutes": "Once every 30 minutes",
"every_hour": "Once every hour",
"every_six_hours": "Once every six hours",
"every_day": "Once every day",
"Everyone_can_access_this_channel": "Everyone can access this channel",
"Example_s": "Example: <code class=\"inline\">%s</code>",
"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 <strong>\"%s\"</strong> 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 <a href='https://rocket.chat/docs/administrator-guides/retention-policies/'>here</a>.",
"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",

@ -0,0 +1,7 @@
Meteor.startup(function() {
RocketChat.Notifications.onLogged('Users:Deleted', ({ userId }) =>
ChatMessage.remove({
'u._id': userId
})
);
});

@ -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');

@ -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;
};

@ -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

@ -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 });
}
});

@ -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 });
}
});

@ -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 });
}

@ -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 = {};

@ -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');
});

@ -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();
});
});

@ -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
});
});
});

@ -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 {

@ -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'))
});
});

@ -0,0 +1,142 @@
<template name="cleanHistory">
{{#unless busy}}
<main class="rc-user-info__scroll">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ "Newer_than"}}</div>
<div class="rc-input__wrapper rc-datetime__left">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg" icon="calendar"}}
</div>
<input type="date" class="rc-input__element" value="" name="from__date" autocomplete="off" placeholder="YYYY-MM-DD"/>
</div>
<div class="rc-input__wrapper rc-datetime__right">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg" icon="clock"}}
</div>
<input type="time" class="rc-input__element" value="" name="from__time" autocomplete="off" placeholder="HH:MM"/>
</div>
</label>
</div>
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ "Older_than"}}</div>
<div class="rc-input__wrapper rc-datetime__left">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg" icon="calendar"}}
</div>
<input type="date" class="rc-input__element" value="" name="to__date" autocomplete="off" placeholder="YYYY-MM-DD"/>
</div>
<div class="rc-input__wrapper rc-datetime__right">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg" icon="clock"}}
</div>
<input type="time" class="rc-input__element" value="" name="to__time" autocomplete="off" placeholder="HH:MM"/>
</div>
</label>
</div>
<div class="rc-input rc-input--usernames">
<label class="rc-input__label">
<div class="rc-input__title">{{_ "Only_from_users"}}</div>
<div class="rc-input__wrapper">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg" icon="at"}}
</div>
<div class="rc-tags">
{{#each user in selectedUsers}}
{{> tag user}}
{{/each}}
<input type="text" class="rc-tags__input" placeholder="{{_ "Username_Placeholder"}}" name="users" autocomplete="off"/>
</div>
</div>
{{#with config}}
{{#if autocomplete 'isShowing'}}
{{#if autocomplete 'isLoaded'}}
{{> popupList data=config items=items}}
{{/if}}
{{/if}}
{{/with}}
</label>
</div>
<label class="rc-checkbox">
<input type="checkbox" name="inclusive" class="rc-checkbox__input">
{{> icon icon="check" block="rc-checkbox__icon"}}
<span class="rc-checkbox__text rc-text__small">{{_ "Inclusive"}}</span>
</label>
<label class="rc-checkbox">
<input type="checkbox" name="excludePinned" class="rc-checkbox__input">
{{> icon icon="check" block="rc-checkbox__icon"}}
<span class="rc-checkbox__text rc-text__small">{{_ "Exclude_pinned"}}</span>
</label>
<label class="rc-checkbox">
<input type="checkbox" name="filesOnly" class="rc-checkbox__input">
{{> icon icon="check" block="rc-checkbox__icon"}}
<span class="rc-checkbox__text rc-text__small">{{_ "Files_only"}}</span>
</label>
{{#unless validate}}
<div class="mail-messages__instructions mail-messages__instructions--warning">
<div class="mail-messages__instructions-wrapper">
{{> icon block="mail-messages__instructions-icon" icon="warning"}}
<div class="mail-messages__instructions-text">
{{warningBox}}
</div>
</div>
</div>
{{else}}
<div class="mail-messages__instructions mail-messages__instructions--warning">
<div class="mail-messages__instructions-wrapper">
{{> icon block="mail-messages__instructions-icon" icon="modal-error"}}
<div class="mail-messages__instructions-text">
{{validate}}
</div>
</div>
</div>
{{/unless}}
</main>
<div class="rc-user-info__flex rc-user-info__row">
<button class="rc-button rc-button--cancel js-prune" title="{{_ 'Prune'}}" disabled="{{#with validate}}{{.}}{{/with}}">{{_ 'Prune'}}</button>
</div>
{{else}}
{{#unless finished}}
<main class="rc-user-info__pruning">
<p class="pruning__header">
{{#unless filesOnly}}
{{_ "Pruning_messages"}}
{{else}}
{{_ "Pruning_files"}}
{{/unless}}
</p>
<div class="pruning-wrapper">
{{> icon icon="loading-thin"}}
<div class="pruning__text">
{{prunedCount}}
</div>
<div class="pruning__text-sub">
{{#unless filesOnly}}
{{_ "messages_pruned"}}
{{else}}
{{_ "files_pruned"}}
{{/unless}}
</div>
</div>
</main>
{{else}}
<main class="rc-user-info__pruning">
<p class="pruning__header">{{_ "Prune_finished"}}</p>
<div class="pruning-wrapper prune__finished">
{{> icon icon="loading-thin-done"}}
<div class="pruning__text">
{{prunedCount}}
</div>
<div class="pruning__text-sub">
{{#unless filesOnly}}
{{_ "messages_pruned"}}
{{else}}
{{_ "files_pruned"}}
{{/unless}}
</div>
</div>
</main>
{{/unless}}
{{/unless}}
</template>

@ -0,0 +1,352 @@
/* globals AutoComplete */
import moment from 'moment';
import { call } from 'meteor/rocketchat:lib';
const getRoomName = function() {
const room = ChatRoom.findOne(Session.get('openedRoom'));
if (!room) {
return;
}
if (room.name) {
return `#${ room.name }`;
}
return t('conversation_with_s', RocketChat.roomTypes.getRoomName(room.t, room));
};
const purgeWorker = function(roomId, oldest, latest, inclusive, limit, excludePinned, filesOnly, fromUsers) {
return call('cleanRoomHistory', {
roomId,
latest,
oldest,
inclusive,
limit,
excludePinned,
filesOnly,
fromUsers
});
};
const getTimeZoneOffset = function() {
const offset = new Date().getTimezoneOffset();
const absOffset = Math.abs(offset);
return `${ offset < 0 ? '+' : '-' }${ (`00${ Math.floor(absOffset / 60) }`).slice(-2) }:${ (`00${ (absOffset % 60) }`).slice(-2) }`;
};
const filterNames = (old) => {
const reg = new RegExp(`^${ RocketChat.settings.get('UTF8_Names_Validation') }$`);
return [...old.replace(' ', '').toLocaleLowerCase()].filter(f => reg.test(f)).join('');
};
Template.cleanHistory.helpers({
roomId() {
const room = ChatRoom.findOne(Session.get('openedRoom'));
return room && room._id;
},
roomName() {
return getRoomName();
},
warningBox() {
return Template.instance().warningBox.get();
},
validate() {
return Template.instance().validate.get();
},
filesOnly() {
return Template.instance().cleanHistoryFilesOnly.get();
},
busy() {
return Template.instance().cleanHistoryBusy.get();
},
finished() {
return Template.instance().cleanHistoryFinished.get();
},
prunedCount() {
return Template.instance().cleanHistoryPrunedCount.get();
},
config() {
const filter = Template.instance().userFilter;
return {
filter: filter.get(),
noMatchTemplate: 'userSearchEmpty',
modifier(text) {
const f = filter.get();
return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), function(part) {
return `<strong>${ part }</strong>`;
}) }`;
}
};
},
autocompleteSettings() {
return {
limit: 10,
rules: [
{
collection: 'CachedChannelList',
subscription: 'userAutocomplete',
field: 'username',
template: Template.userSearch,
noMatchTemplate: Template.userSearchEmpty,
matchAll: true,
filter: {
exceptions: Template.instance().selectedUsers.get()
},
selector(match) {
return {
term: match
};
},
sort: 'username'
}
]
};
},
selectedUsers() {
return Template.instance().selectedUsers.get();
},
autocomplete(key) {
const instance = Template.instance();
const param = instance.ac[key];
return typeof param === 'function' ? param.apply(instance.ac): param;
},
items() {
return Template.instance().ac.filteredList();
}
});
Template.cleanHistory.onCreated(function() {
this.warningBox = new ReactiveVar('');
this.validate = new ReactiveVar('');
this.selectedUsers = new ReactiveVar([]);
this.userFilter = new ReactiveVar('');
this.cleanHistoryFromDate = new ReactiveVar('');
this.cleanHistoryFromTime = new ReactiveVar('');
this.cleanHistoryToDate = new ReactiveVar('');
this.cleanHistoryToTime = new ReactiveVar('');
this.cleanHistorySelectedUsers = new ReactiveVar([]);
this.cleanHistoryInclusive = new ReactiveVar(false);
this.cleanHistoryExcludePinned = new ReactiveVar(false);
this.cleanHistoryFilesOnly = new ReactiveVar(false);
this.cleanHistoryBusy = new ReactiveVar(false);
this.cleanHistoryFinished = new ReactiveVar(false);
this.cleanHistoryPrunedCount = new ReactiveVar(0);
this.ac = new AutoComplete(
{
selector:{
item: '.rc-popup-list__item',
container: '.rc-popup-list__list'
},
limit: 10,
inputDelay: 300,
rules: [
{
collection: 'UserAndRoom',
subscription: 'userAutocomplete',
field: 'username',
matchAll: true,
doNotChangeWidth: false,
selector(match) {
return { term: match };
},
sort: 'username'
}
]
});
this.ac.tmplInst = this;
});
Template.cleanHistory.onRendered(function() {
const users = this.selectedUsers;
this.ac.element = this.firstNode.parentElement.querySelector('[name="users"]');
this.ac.$element = $(this.ac.element);
this.ac.$element.on('autocompleteselect', function(e, {item}) {
const usersArr = users.get();
usersArr.push(item);
users.set(usersArr);
Session.set('cleanHistorySelectedUsers', usersArr);
});
Tracker.autorun(() => {
const metaFromDate = this.cleanHistoryFromDate.get();
const metaFromTime = this.cleanHistoryFromTime.get();
const metaToDate = this.cleanHistoryToDate.get();
const metaToTime = this.cleanHistoryToTime.get();
const metaSelectedUsers = this.cleanHistorySelectedUsers.get();
const metaCleanHistoryExcludePinned = this.cleanHistoryExcludePinned.get();
const metaCleanHistoryFilesOnly = this.cleanHistoryFilesOnly.get();
let fromDate = new Date('0001-01-01T00:00:00Z');
let toDate = new Date('9999-12-31T23:59:59Z');
if (metaFromDate) {
fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`);
}
if (metaToDate) {
toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`);
}
const exceptPinned = metaCleanHistoryExcludePinned ? ` ${ t('except_pinned', {}) }` : '';
const ifFrom = metaSelectedUsers.length ? ` ${ t('if_they_are_from', {
postProcess: 'sprintf',
sprintf: [metaSelectedUsers.map(element => element.username).join(', ')]
}) }` : '';
const filesOrMessages = t(metaCleanHistoryFilesOnly ? 'files' : 'messages', {});
if (metaFromDate && metaToDate) {
this.warningBox.set(t('Prune_Warning_between', {
postProcess: 'sprintf',
sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT'), moment(toDate).format('L LT')]
}) + exceptPinned + ifFrom);
} else if (metaFromDate) {
this.warningBox.set(t('Prune_Warning_after', {
postProcess: 'sprintf',
sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT')]
}) + exceptPinned + ifFrom);
} else if (metaToDate) {
this.warningBox.set(t('Prune_Warning_before', {
postProcess: 'sprintf',
sprintf: [filesOrMessages, getRoomName(), moment(toDate).format('L LT')]
}) + exceptPinned + ifFrom);
} else {
this.warningBox.set(t('Prune_Warning_all', {
postProcess: 'sprintf',
sprintf: [filesOrMessages, getRoomName()]
}) + exceptPinned + ifFrom);
}
if (fromDate > toDate) {
return this.validate.set(t('Newer_than_may_not_exceed_Older_than', {
postProcess: 'sprintf',
sprintf: []
}));
}
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return this.validate.set(t('error-invalid-date', {
postProcess: 'sprintf',
sprintf: []
}));
}
this.validate.set('');
});
});
Template.cleanHistory.events({
'change [name=from__date]'(e, instance) {
instance.cleanHistoryFromDate.set(e.target.value);
},
'change [name=from__time]'(e, instance) {
instance.cleanHistoryFromTime.set(e.target.value);
},
'change [name=to__date]'(e, instance) {
instance.cleanHistoryToDate.set(e.target.value);
},
'change [name=to__time]'(e, instance) {
instance.cleanHistoryToTime.set(e.target.value);
},
'change [name=inclusive]'(e, instance) {
instance.cleanHistoryInclusive.set(e.target.checked);
},
'change [name=excludePinned]'(e, instance) {
instance.cleanHistoryExcludePinned.set(e.target.checked);
},
'change [name=filesOnly]'(e, instance) {
instance.cleanHistoryFilesOnly.set(e.target.checked);
},
'click .js-prune'(e, instance) {
modal.open({
title: t('Are_you_sure'),
text: t('Prune_Modal'),
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#DD6B55',
confirmButtonText: t('Yes_prune_them'),
cancelButtonText: t('Cancel'),
closeOnConfirm: true,
html: false
}, async function() {
instance.cleanHistoryBusy.set(true);
const metaFromDate = instance.cleanHistoryFromDate.get();
const metaFromTime = instance.cleanHistoryFromTime.get();
const metaToDate = instance.cleanHistoryToDate.get();
const metaToTime = instance.cleanHistoryToTime.get();
const metaSelectedUsers = instance.cleanHistorySelectedUsers.get();
const metaCleanHistoryInclusive = instance.cleanHistoryInclusive.get();
const metaCleanHistoryExcludePinned = instance.cleanHistoryExcludePinned.get();
const metaCleanHistoryFilesOnly = instance.cleanHistoryFilesOnly.get();
let fromDate = new Date('0001-01-01T00:00:00Z');
let toDate = new Date('9999-12-31T23:59:59Z');
if (metaFromDate) {
fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`);
}
if (metaToDate) {
toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`);
}
const roomId = Session.get('openedRoom');
const users = metaSelectedUsers.map(element => element.username);
const limit = 2000;
let count = 0;
let result;
do {
result = await purgeWorker(roomId, fromDate, toDate, metaCleanHistoryInclusive, limit, metaCleanHistoryExcludePinned, metaCleanHistoryFilesOnly, users);
count += result;
} while (result === limit);
instance.cleanHistoryPrunedCount.set(count);
instance.cleanHistoryFinished.set(true);
});
},
'click .rc-input--usernames .rc-tags__tag'({target}, t) {
const {username} = Blaze.getData(target);
t.selectedUsers.set(t.selectedUsers.get().filter(user => user.username !== username));
Session.set('cleanHistorySelectedUsers', t.selectedUsers.get());
},
'click .rc-popup-list__item'(e, t) {
t.ac.onItemClick(this, e);
},
'input [name="users"]'(e, t) {
const input = e.target;
const position = input.selectionEnd || input.selectionStart;
const length = input.value.length;
const modified = filterNames(input.value);
input.value = modified;
document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length);
t.userFilter.set(modified);
},
'keydown [name="users"]'(e, t) {
if ([8, 46].includes(e.keyCode) && e.target.value === '') {
const users = t.selectedUsers;
const usersArr = users.get();
usersArr.pop();
Session.set('cleanHistorySelectedUsers', usersArr);
return users.set(usersArr);
}
t.ac.onKeyDown(e);
},
'keyup [name="users"]'(e, t) {
t.ac.onKeyUp(e);
},
'focus [name="users"]'(e, t) {
t.ac.onFocus(e);
},
'blur [name="users"]'(e, t) {
t.ac.onBlur(e);
}
});

@ -0,0 +1,52 @@
.rc-datetime__left {
display: inline-block;
width: 52%;
}
.rc-datetime__right {
display: inline-block;
width: calc(48% - 0.3rem);
}
.rc-user-info__pruning {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, calc(-50% - 2rem));
}
.pruning__header {
text-align: center;
font-weight: 900;
}
.pruning-wrapper {
text-align: center;
color: var(--rc-color-link-active);
&.prune__finished {
color: #12C212;
}
& svg {
width: 16rem;
height: 16rem;
margin: 1rem 0;
animation: pruningSpin 2s linear infinite;
}
& .pruning__text {
margin-top: -17rem;
line-height: 16rem;
font-size: 3.5em;
}
& .pruning__text-sub {
margin-top: calc(-8rem + 1.5em);
}
}
@keyframes pruningSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

@ -0,0 +1,27 @@
Package.describe({
name: 'rocketchat:ui-clean-history',
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([
'client/lib/startup.js',
'client/views/cleanHistory.html',
'client/views/cleanHistory.js',
'client/views/stylesheets/cleanHistory.css'
], 'client');
});

@ -33,6 +33,9 @@
<symbol id="icon-bold" viewBox="0 0 20 20">
<path d="M10.627 16.25H6.16V4.8h4.38c1.052 0 1.88.257 2.483.77.603.513.905 1.209.905 2.087a2.49 2.49 0 0 1-.576 1.603c-.383.476-.85.764-1.4.865v.126c.783.101 1.404.401 1.865.901.46.5.69 1.128.69 1.884 0 1.006-.343 1.792-1.028 2.361-.685.569-1.636.853-2.852.853zM7.587 6.062v3.674h2.286c.868 0 1.52-.154 1.956-.46.436-.307.655-.765.655-1.373 0-.582-.198-1.034-.592-1.357-.394-.323-.945-.484-1.654-.484h-2.65zm0 8.926h2.826c.862 0 1.515-.172 1.96-.515.444-.344.666-.847.666-1.508s-.231-1.16-.694-1.496c-.463-.335-1.152-.503-2.067-.503h-2.69v4.022z" fill-rule="evenodd" />
</symbol>
<symbol id="icon-calendar" viewBox="0 0 448 512">
<path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H160V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zm-6 400H54c-3.3 0-6-2.7-6-6V160h352v298c0 3.3-2.7 6-6 6z"/>
</symbol>
<symbol id="icon-chat" viewBox="0 0 28 28">
<path d="M11 6c-4.875 0-9 2.75-9 6 0 1.719 1.156 3.375 3.156 4.531l1.516 0.875-0.547 1.313c0.328-0.187 0.656-0.391 0.969-0.609l0.688-0.484 0.828 0.156c0.781 0.141 1.578 0.219 2.391 0.219 4.875 0 9-2.75 9-6s-4.125-6-9-6zM11 4c6.078 0 11 3.578 11 8s-4.922 8-11 8c-0.953 0-1.875-0.094-2.75-0.25-1.297 0.922-2.766 1.594-4.344 2-0.422 0.109-0.875 0.187-1.344 0.25h-0.047c-0.234 0-0.453-0.187-0.5-0.453v0c-0.063-0.297 0.141-0.484 0.313-0.688 0.609-0.688 1.297-1.297 1.828-2.594-2.531-1.469-4.156-3.734-4.156-6.266 0-4.422 4.922-8 11-8zM23.844 22.266c0.531 1.297 1.219 1.906 1.828 2.594 0.172 0.203 0.375 0.391 0.313 0.688v0c-0.063 0.281-0.297 0.484-0.547 0.453-0.469-0.063-0.922-0.141-1.344-0.25-1.578-0.406-3.047-1.078-4.344-2-0.875 0.156-1.797 0.25-2.75 0.25-2.828 0-5.422-0.781-7.375-2.063 0.453 0.031 0.922 0.063 1.375 0.063 3.359 0 6.531-0.969 8.953-2.719 2.609-1.906 4.047-4.484 4.047-7.281 0-0.812-0.125-1.609-0.359-2.375 2.641 1.453 4.359 3.766 4.359 6.375 0 2.547-1.625 4.797-4.156 6.266z" />
</symbol>
@ -241,6 +244,12 @@
<animateTransform attributeName="transform" type="rotate" calcMode="linear" values="0 50 50;360 50 50" keyTimes="0;1" dur="1s" begin="0s" repeatCount="indefinite" />
</circle>
</symbol>
<symbol id="icon-loading-thin" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" class="lds-rolling">
<circle cx="50" cy="50" fill="none" stroke="currentColor" stroke-width="1" r="46.8" stroke-dasharray="220.5398042820034 77.11326809400116"></circle>
</symbol>
<symbol id="icon-loading-thin-done" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" class="lds-rolling">
<circle cx="50" cy="50" fill="none" stroke="currentColor" stroke-width="1" r="46.8"></circle>
</symbol>
<symbol id="icon-lock" viewBox="0 0 20 20">
<path d="M5 8h10v9H5zm2-3c0-1.657 1.347-3 3-3 1.657 0 3 1.347 3 3v3H7V5z" stroke="currentColor" stroke-width="1.5" fill="none" />
</symbol>
@ -472,4 +481,9 @@
<path d="M10 9v5" stroke-linecap="square" />
</g>
</symbol>
<symbol id="icon-warning-empty" viewBox="0 0 20 20">
<g stroke="currentColor" stroke-width="1.5" fill="none" fill-rule="evenodd">
<path d="M10 3l8 14H2z" />
</g>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

@ -1,5 +1,18 @@
import _ from 'underscore';
import { upsertMessage } from './RoomHistoryManager';
const onDeleteMessageStream = msg => ChatMessage.remove({ _id: msg._id });
const onDeleteMessageBulkStream = ({rid, ts, excludePinned, users}) => {
const query = { rid, ts };
if (excludePinned) {
query.pinned = { $ne: true };
}
if (users && users.length) {
query['u.username'] = { $in: users };
}
ChatMessage.remove(query);
};
const RoomManager = new function() {
const openedRooms = {};
const msgStream = new Meteor.Streamer('room-messages');
@ -58,6 +71,7 @@ const RoomManager = new function() {
);
RocketChat.Notifications.onRoom(openedRooms[typeName].rid, 'deleteMessage', onDeleteMessageStream); // eslint-disable-line no-use-before-define
RocketChat.Notifications.onRoom(openedRooms[typeName].rid, 'deleteMessageBulk', onDeleteMessageBulkStream); // eslint-disable-line no-use-before-define
}
}
Meteor.defer(() => {
@ -95,6 +109,7 @@ const RoomManager = new function() {
if (openedRooms[typeName].rid != null) {
msgStream.removeAllListeners(openedRooms[typeName].rid);
RocketChat.Notifications.unRoom(openedRooms[typeName].rid, 'deleteMessage', onDeleteMessageStream); // eslint-disable-line no-use-before-define
RocketChat.Notifications.unRoom(openedRooms[typeName].rid, 'deleteMessageBulk', onDeleteMessageBulkStream); // eslint-disable-line no-use-before-define
}
openedRooms[typeName].ready = false;
@ -278,10 +293,6 @@ Meteor.startup(() => {
});
});
const onDeleteMessageStream = msg => ChatMessage.remove({_id: msg._id});
Tracker.autorun(function() {
if (Meteor.userId()) {
return RocketChat.Notifications.onUser('message', function(msg) {

@ -2,7 +2,7 @@ import _ from 'underscore';
this.t = function(key, ...replaces) {
if (_.isObject(replaces[0])) {
return TAPi18n.__(key, replaces);
return TAPi18n.__(key, ...replaces);
} else {
return TAPi18n.__(key, {
postProcess: 'sprintf',
@ -13,7 +13,7 @@ this.t = function(key, ...replaces) {
this.tr = function(key, options, ...replaces) {
if (_.isObject(replaces[0])) {
return TAPi18n.__(key, options, replaces);
return TAPi18n.__(key, options, ...replaces);
} else {
return TAPi18n.__(key, options, {
postProcess: 'sprintf',

@ -108,6 +108,25 @@
</li>
{{else}}
<li class="start color-info-font-color">
{{#if hasPurge}}
<div class="start__purge-warning error-background error-border error-color">
{{> icon block="start__purge-warning-icon" icon="warning-empty"}}
{{#unless filesOnly}}
{{#unless excludePinned}}
{{_ "RetentionPolicy_RoomWarning" purgeTimeout}}
{{else}}
{{_ "RetentionPolicy_RoomWarning_Unpinned" purgeTimeout}}
{{/unless}}
{{else}}
{{#unless excludePinned}}
{{_ "RetentionPolicy_RoomWarning_FilesOnly" purgeTimeout}}
{{else}}
{{_ "RetentionPolicy_RoomWarning_UnpinnedFilesOnly" purgeTimeout}}
{{/unless}}
{{/unless}}
{{> icon block="start__purge-warning-icon" icon="warning-empty"}}
</div>
{{/if}}
{{_ "Start_of_conversation"}}
</li>
{{/if}}

@ -130,6 +130,81 @@ const wipeFailedUploads = () => {
}
};
function roomHasGlobalPurge(room) {
if (!RocketChat.settings.get('RetentionPolicy_Enabled')) {
return false;
}
switch (room.t) {
case 'c':
return RocketChat.settings.get('RetentionPolicy_AppliesToChannels');
case 'p':
return RocketChat.settings.get('RetentionPolicy_AppliesToGroups');
case 'd':
return RocketChat.settings.get('RetentionPolicy_AppliesToDMs');
}
return false;
}
function roomHasPurge(room) {
if (!room || !RocketChat.settings.get('RetentionPolicy_Enabled')) {
return false;
}
if (room.retention && room.retention.enabled !== undefined) {
return room.retention.enabled;
}
return roomHasGlobalPurge(room);
}
function roomFilesOnly(room) {
if (!room) {
return false;
}
if (room.retention && room.retention.overrideGlobal) {
return room.retention.filesOnly;
}
return RocketChat.settings.get('RetentionPolicy_FilesOnly');
}
function roomExcludePinned(room) {
if (!room) {
return false;
}
if (room.retention && room.retention.overrideGlobal) {
return room.retention.excludePinned;
}
return RocketChat.settings.get('RetentionPolicy_ExcludePinned');
}
function roomMaxAge(room) {
if (!room) {
return;
}
if (!roomHasPurge(room)) {
return;
}
if (room.retention && room.retention.overrideGlobal) {
return room.retention.maxAge;
}
if (room.t === 'c') {
return RocketChat.settings.get('RetentionPolicy_MaxAge_Channels');
}
if (room.t === 'p') {
return RocketChat.settings.get('RetentionPolicy_MaxAge_Groups');
}
if (room.t === 'd') {
return RocketChat.settings.get('RetentionPolicy_MaxAge_DMs');
}
}
RocketChat.callbacks.add('enter-room', wipeFailedUploads);
Template.room.helpers({
@ -388,6 +463,25 @@ Template.room.helpers({
if (RoomRoles.findOne({ rid: this._id, roles: 'leader', 'u._id': { $ne: Meteor.userId() } }, { fields: { _id: 1 } })) {
return 'has-leader';
}
},
hasPurge() {
return roomHasPurge(Session.get(`roomData${ this._id }`));
},
filesOnly() {
return roomFilesOnly(Session.get(`roomData${ this._id }`));
},
excludePinned() {
return roomExcludePinned(Session.get(`roomData${ this._id }`));
},
purgeTimeout() {
moment.relativeTimeThreshold('s', 60);
moment.relativeTimeThreshold('ss', 0);
moment.relativeTimeThreshold('m', 60);
moment.relativeTimeThreshold('h', 24);
moment.relativeTimeThreshold('d', 31);
moment.relativeTimeThreshold('M', 12);
return moment.duration(roomMaxAge(Session.get(`roomData${ this._id }`)) * 1000 * 60 * 60 * 24).humanize();
}
});

@ -25,20 +25,21 @@ Meteor.methods({
}
}
if (RocketChat.roomTypes.roomTypes[room.t].canBeDeleted(room)) {
RocketChat.models.Messages.removeByRoomId(rid);
RocketChat.models.Subscriptions.removeByRoomId(rid);
const result = RocketChat.models.Rooms.removeById(rid);
if (Apps && Apps.isLoaded()) {
Apps.getBridges().getListenerBridge().roomEvent('IPostRoomDeleted', room);
}
return result;
} else {
if (!RocketChat.roomTypes.roomTypes[room.t].canBeDeleted(room)) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'eraseRoom'
});
}
RocketChat.models.Messages.removeFilesByRoomId(rid);
RocketChat.models.Messages.removeByRoomId(rid);
RocketChat.models.Subscriptions.removeByRoomId(rid);
const result = RocketChat.models.Rooms.removeById(rid);
if (Apps && Apps.isLoaded()) {
Apps.getBridges().getListenerBridge().roomEvent('IPostRoomDeleted', room);
}
return result;
}
});

@ -19,6 +19,7 @@ const fields = {
default: 1,
customFields: 1,
lastMessage: 1,
retention: 1,
// @TODO create an API to register this fields based on room type
livechatData: 1,

@ -290,24 +290,6 @@ describe('[Channels]', function() {
.end(done);
});
//DEPRECATED
// TODO: Remove this after three versions have been released. That means at 0.67 this should be gone.
it('/channels.cleanHistory', (done) => {
request.post(api('channels.cleanHistory'))
.set(credentials)
.send({
roomId: channel._id,
latest: '2016-12-09T13:42:25.304Z',
oldest: '2016-08-30T13:42:25.304Z'
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
})
.end(done);
});
it('/channels.archive', (done) => {
request.post(api('channels.archive'))
.set(credentials)

Loading…
Cancel
Save