* Added scaffolding for unrevealed BoR post * Displayed reveal UI * Displayed expiry timer * WIP * Displayed own post indicator and blur text * restored button * Ungrouped BoR posts * WIP * WIP * DIsplayed error message on revealing * Added last run check on mobile app cleanup job * Cleanup * lint fix * i18n-fix * Added tests * test: add test suite for revealBoRPost function * test: add unrevealed burn on read post test file * feat: add tests for UnrevealedBurnOnReadPost component * test: update UnrevealedBurnOnReadPost test with PostModel type * test: replace toBeTruthy with toBeVisible for component visibility assertions * test: add initial test file for expiry timer component * test: add comprehensive tests for ExpiryCountdown component * refactor: clean up test formatting and remove redundant test case * fix: adjust test timing for ExpiryCountdown onExpiry callback * test: fix timer test by advancing timers in smaller increments * Added tests * Updated tests * fixed accidental change * restored package.resolved * WIP review fixes * Review fixes * Review fixes * Fixed tests * restored package.resolved * WIP * test: add test for skipping BoR post cleanup within 15 minutes * test: add comprehensive test cases for expiredBoRPostCleanup * WIP * WIP * test: add bor.test.ts placeholder file * test: add comprehensive tests for BoR utility functions * test: add comprehensive tests for formatTime function * WIP * test: add comprehensive tests for getLastBoRPostCleanupRun function * Added tests * removed a commented code * post list optimization * test: add test case for updating unrevealed burn-on-read post * fix: handle error logging in expiredBoRPostCleanup test * test: add test case for updateLastBoRCleanupRun error handling * review fixes * lint fixes * Added WS event handling (#9320) * Added WS event handling * test: add burn on read websocket action test * test: add tests for handleBoRPostRevealedEvent in burn_on_read * Added tests * test: add comprehensive error handling test cases for burn on read * Added tests and error handling * BoR post - restricted actions (#9315) * Restricted post actions for BoR post type * Prevent opening thread fr nBoR post * WIP * fix: remove redundant observable wrapping in post options * fixed a change * removed broken * restored package.resolved * Added tests * Awaiting for last run to be set * Added tests for validating expiry timer behaviour (#9338) * Added tests for validating expiry timer behaviour * No need of use event setup * Bor ux fixes (#9336) * WIP * UI and delete fixes * lint fixes * fixed tests * Improved mocking * Improved test
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {Q} from '@nozbe/watermelondb';
|
|
import deepEqual from 'deep-equal';
|
|
import {DeviceEventEmitter} from 'react-native';
|
|
|
|
import {Events} from '@constants';
|
|
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
|
import {PostTypes, BOR_POST_CLEANUP_MIN_RUN_INTERVAL} from '@constants/post';
|
|
import DatabaseManager from '@database/manager';
|
|
import {getServerCredentials} from '@init/credentials';
|
|
import {queryAllChannelsForTeam} from '@queries/servers/channel';
|
|
import {queryPostsByType} from '@queries/servers/post';
|
|
import {
|
|
getConfig,
|
|
getLicense,
|
|
getGlobalDataRetentionPolicy,
|
|
getGranularDataRetentionPolicies,
|
|
getLastGlobalDataRetentionRun,
|
|
getIsDataRetentionEnabled,
|
|
getLastBoRPostCleanupRun,
|
|
} from '@queries/servers/system';
|
|
import PostModel from '@typings/database/models/servers/post';
|
|
import {isExpiredBoRPost} from '@utils/bor';
|
|
import {logError} from '@utils/log';
|
|
|
|
import {deletePosts} from './post';
|
|
|
|
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
|
|
|
|
const {SERVER: {POST}} = MM_TABLES;
|
|
|
|
export async function storeConfigAndLicense(serverUrl: string, config: ClientConfig, license: ClientLicense) {
|
|
try {
|
|
// If we have credentials for this server then update the values in the database
|
|
const credentials = await getServerCredentials(serverUrl);
|
|
if (credentials) {
|
|
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const currentLicense = await getLicense(database);
|
|
const systems: IdValue[] = [];
|
|
|
|
if (!deepEqual(license, currentLicense)) {
|
|
systems.push({
|
|
id: SYSTEM_IDENTIFIERS.LICENSE,
|
|
value: JSON.stringify(license),
|
|
});
|
|
}
|
|
|
|
if (systems.length) {
|
|
await operator.handleSystem({systems, prepareRecordsOnly: false});
|
|
DeviceEventEmitter.emit(Events.LICENSE_CHANGED, {serverUrl, license});
|
|
}
|
|
|
|
return await storeConfig(serverUrl, config);
|
|
}
|
|
} catch (error) {
|
|
logError('An error occurred while saving config & license', error);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export async function storeConfig(serverUrl: string, config: ClientConfig | undefined, prepareRecordsOnly = false) {
|
|
if (!config) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const currentConfig = await getConfig(database);
|
|
const configsToUpdate: IdValue[] = [];
|
|
const configsToDelete: IdValue[] = [];
|
|
|
|
let k: keyof ClientConfig;
|
|
for (k in config) {
|
|
if (currentConfig?.[k] !== config[k]) {
|
|
configsToUpdate.push({
|
|
id: k,
|
|
value: config[k],
|
|
});
|
|
}
|
|
}
|
|
for (k in currentConfig) {
|
|
if (config[k] === undefined) {
|
|
configsToDelete.push({
|
|
id: k,
|
|
value: currentConfig[k],
|
|
});
|
|
}
|
|
}
|
|
|
|
if (configsToDelete.length || configsToUpdate.length) {
|
|
const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
|
|
DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config});
|
|
return results;
|
|
}
|
|
} catch (error) {
|
|
logError('storeConfig', error);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export async function storeDataRetentionPolicies(serverUrl: string, data: DataRetentionPoliciesRequest, prepareRecordsOnly = false) {
|
|
try {
|
|
const {globalPolicy, teamPolicies, channelPolicies} = data;
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const systems: IdValue[] = [{
|
|
id: SYSTEM_IDENTIFIERS.DATA_RETENTION_POLICIES,
|
|
value: globalPolicy || {},
|
|
}, {
|
|
id: SYSTEM_IDENTIFIERS.GRANULAR_DATA_RETENTION_POLICIES,
|
|
value: {
|
|
team: teamPolicies || [],
|
|
channel: channelPolicies || [],
|
|
},
|
|
}];
|
|
|
|
return operator.handleSystem({
|
|
systems,
|
|
prepareRecordsOnly,
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function updateLastDataRetentionRun(serverUrl: string, value?: number, prepareRecordsOnly = false) {
|
|
try {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const systems: IdValue[] = [{
|
|
id: SYSTEM_IDENTIFIERS.LAST_DATA_RETENTION_RUN,
|
|
value: value || Date.now(),
|
|
}];
|
|
|
|
return operator.handleSystem({systems, prepareRecordsOnly});
|
|
} catch (error) {
|
|
logError('Failed updateLastDataRetentionRun', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
export async function dataRetentionCleanup(serverUrl: string) {
|
|
try {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const lastRunAt = await getLastGlobalDataRetentionRun(database);
|
|
const lastCleanedToday = new Date(lastRunAt).toDateString() === new Date().toDateString();
|
|
|
|
// Do not run if clean up is already done today
|
|
if (lastRunAt && lastCleanedToday) {
|
|
return {error: undefined};
|
|
}
|
|
|
|
const isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
|
|
const result = await (isDataRetentionEnabled ? dataRetentionPolicyCleanup(serverUrl) : dataRetentionWithoutPolicyCleanup(serverUrl));
|
|
|
|
if (!result.error) {
|
|
await updateLastDataRetentionRun(serverUrl);
|
|
}
|
|
|
|
await database.unsafeVacuum();
|
|
|
|
return result;
|
|
} catch (error) {
|
|
logError('An error occurred while performing data retention cleanup', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
async function dataRetentionPolicyCleanup(serverUrl: string) {
|
|
try {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const globalPolicy = await getGlobalDataRetentionPolicy(database);
|
|
const granularPoliciesData = await getGranularDataRetentionPolicies(database);
|
|
|
|
// Get global data retention cutoff
|
|
let globalRetentionCutoff = 0;
|
|
if (globalPolicy?.message_deletion_enabled) {
|
|
globalRetentionCutoff = globalPolicy.message_retention_cutoff;
|
|
}
|
|
|
|
// Get Granular data retention policies
|
|
let teamPolicies: TeamDataRetentionPolicy[] = [];
|
|
let channelPolicies: ChannelDataRetentionPolicy[] = [];
|
|
if (granularPoliciesData) {
|
|
teamPolicies = granularPoliciesData.team;
|
|
channelPolicies = granularPoliciesData.channel;
|
|
}
|
|
|
|
const channelsCutoffs: {[key: string]: number} = {};
|
|
|
|
// Get channel level cutoff from team policies
|
|
for await (const teamPolicy of teamPolicies) {
|
|
const {team_id, post_duration} = teamPolicy;
|
|
const channelIds = await queryAllChannelsForTeam(database, team_id).fetchIds();
|
|
if (channelIds.length) {
|
|
const cutoff = getDataRetentionPolicyCutoff(post_duration);
|
|
channelIds.forEach((channelId) => {
|
|
channelsCutoffs[channelId] = cutoff;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get channel level cutoff from channel policies
|
|
channelPolicies.forEach(({channel_id, post_duration}) => {
|
|
channelsCutoffs[channel_id] = getDataRetentionPolicyCutoff(post_duration);
|
|
});
|
|
|
|
const conditions = [];
|
|
|
|
const channelIds = Object.keys(channelsCutoffs);
|
|
if (channelIds.length) {
|
|
// Fetch posts by channel level cutoff
|
|
for (const channelId of channelIds) {
|
|
const cutoff = channelsCutoffs[channelId];
|
|
conditions.push(`(channel_id='${channelId}' AND create_at < ${cutoff})`);
|
|
}
|
|
|
|
// Fetch posts by global cutoff which are not already fetched by channel level cutoff
|
|
conditions.push(`(channel_id NOT IN ('${channelIds.join("','")}') AND create_at < ${globalRetentionCutoff})`);
|
|
} else {
|
|
conditions.push(`create_at < ${globalRetentionCutoff}`);
|
|
}
|
|
|
|
const postIds = await database.get<PostModel>(POST).query(
|
|
Q.unsafeSqlQuery(`SELECT * FROM ${POST} where ${conditions.join(' OR ')}`),
|
|
).fetchIds();
|
|
|
|
return dataRetentionCleanPosts(serverUrl, postIds);
|
|
} catch (error) {
|
|
logError('An error occurred while performing data retention policy cleanup', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
async function dataRetentionWithoutPolicyCleanup(serverUrl: string) {
|
|
try {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const cutoff = getDataRetentionPolicyCutoff(14); // 14 days
|
|
|
|
const postIds = await database.get<PostModel>(POST).query(
|
|
Q.where('create_at', Q.lt(cutoff)),
|
|
).fetchIds();
|
|
|
|
return dataRetentionCleanPosts(serverUrl, postIds);
|
|
} catch (error) {
|
|
logError('An error occurred while performing data retention without policy cleanup', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
export async function dataRetentionCleanPosts(serverUrl: string, postIds: string[]) {
|
|
if (postIds.length) {
|
|
const batchSize = 1000;
|
|
const deletePromises = [];
|
|
for (let i = 0; i < postIds.length; i += batchSize) {
|
|
const batch = postIds.slice(i, batchSize);
|
|
deletePromises.push(
|
|
deletePosts(serverUrl, batch),
|
|
);
|
|
}
|
|
const deleteResult = await Promise.all(deletePromises);
|
|
for (const {error} of deleteResult) {
|
|
if (error) {
|
|
return {error};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {error: undefined};
|
|
}
|
|
|
|
// Returns cutoff time based on the policy's post_duration
|
|
function getDataRetentionPolicyCutoff(postDuration: number) {
|
|
const periodDate = new Date();
|
|
periodDate.setDate(periodDate.getDate() - postDuration);
|
|
periodDate.setHours(0);
|
|
periodDate.setMinutes(0);
|
|
periodDate.setSeconds(0);
|
|
return periodDate.getTime();
|
|
}
|
|
|
|
export async function setLastServerVersionCheck(serverUrl: string, reset = false) {
|
|
try {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
await operator.handleSystem({
|
|
systems: [{
|
|
id: SYSTEM_IDENTIFIERS.LAST_SERVER_VERSION_CHECK,
|
|
value: reset ? 0 : Date.now(),
|
|
}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
return {error: undefined};
|
|
} catch (error) {
|
|
logError('setLastServerVersionCheck', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
export async function setGlobalThreadsTab(serverUrl: string, globalThreadsTab: GlobalThreadsTab) {
|
|
try {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
await operator.handleSystem({
|
|
systems: [{
|
|
id: SYSTEM_IDENTIFIERS.GLOBAL_THREADS_TAB,
|
|
value: globalThreadsTab,
|
|
}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
return {error: undefined};
|
|
} catch (error) {
|
|
logError('setGlobalThreadsTab', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
export async function dismissAnnouncement(serverUrl: string, announcementText: string) {
|
|
try {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LAST_DISMISSED_BANNER, value: announcementText}], prepareRecordsOnly: false});
|
|
return {error: undefined};
|
|
} catch (error) {
|
|
logError('An error occurred while dismissing an announcement', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
export async function expiredBoRPostCleanup(serverUrl: string) {
|
|
try {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const lastRunAt = await getLastBoRPostCleanupRun(database);
|
|
|
|
const shouldRunNow = (Date.now() - lastRunAt) > BOR_POST_CLEANUP_MIN_RUN_INTERVAL;
|
|
|
|
if (!shouldRunNow) {
|
|
return;
|
|
}
|
|
|
|
const {error} = await removeExpiredBoRPosts(serverUrl);
|
|
if (!error) {
|
|
await updateLastBoRCleanupRun(serverUrl);
|
|
}
|
|
} catch (error) {
|
|
logError('An error occurred running the Burn on Read cleanup task', error);
|
|
}
|
|
}
|
|
|
|
async function removeExpiredBoRPosts(serverUrl: string) {
|
|
try {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const allBoRPosts = await queryPostsByType(database, PostTypes.BURN_ON_READ).fetch();
|
|
const expiredBoRPostIDs = allBoRPosts.
|
|
filter((post) => isExpiredBoRPost(post)).
|
|
map((post) => post.id);
|
|
|
|
await dataRetentionCleanPosts(serverUrl, expiredBoRPostIDs);
|
|
return {error: undefined};
|
|
} catch (error) {
|
|
logError('An error occurred while performing BoR post cleanup', error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
async function updateLastBoRCleanupRun(serverUrl: string) {
|
|
try {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const systems: IdValue[] = [{
|
|
id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN,
|
|
value: Date.now(),
|
|
}];
|
|
|
|
return operator.handleSystem({systems, prepareRecordsOnly: false});
|
|
} catch (error) {
|
|
logError('Failed updateLastBoRCleanupRun', error);
|
|
return {error};
|
|
}
|
|
}
|