1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-02 12:47:41 +02:00

Server: Add task to automate deletion of disabled accounts

This commit is contained in:
Laurent Cozic 2022-02-01 17:55:14 +00:00
parent 68469bc1a5
commit 1afcb27601
8 changed files with 155 additions and 16 deletions

View File

@ -106,6 +106,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
const supportEmail = env.SUPPORT_EMAIL; const supportEmail = env.SUPPORT_EMAIL;
config_ = { config_ = {
...env,
appVersion: packageJson.version, appVersion: packageJson.version,
appName, appName,
isJoplinCloud: apiBaseUrl.includes('.joplincloud.com') || apiBaseUrl.includes('.joplincloud.local'), isJoplinCloud: apiBaseUrl.includes('.joplincloud.com') || apiBaseUrl.includes('.joplincloud.local'),

View File

@ -89,6 +89,13 @@ const defaultEnvValues: EnvVariables = {
STRIPE_SECRET_KEY: '', STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '', STRIPE_WEBHOOK_SECRET: '',
// ==================================================
// User data deletion
// ==================================================
USER_DATA_AUTO_DELETE_ENABLED: false,
USER_DATA_AUTO_DELETE_AFTER_DAYS: 90,
}; };
export interface EnvVariables { export interface EnvVariables {
@ -138,6 +145,9 @@ export interface EnvVariables {
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string; STRIPE_WEBHOOK_SECRET: string;
USER_DATA_AUTO_DELETE_ENABLED: boolean;
USER_DATA_AUTO_DELETE_AFTER_DAYS: number;
} }
const parseBoolean = (s: string): boolean => { const parseBoolean = (s: string): boolean => {

View File

@ -1,4 +1,5 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models, createUser, expectThrow } from '../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, models, createUser, expectThrow } from '../utils/testing/testUtils';
import { Day } from '../utils/time';
describe('UserDeletionModel', function() { describe('UserDeletionModel', function() {
@ -143,4 +144,39 @@ describe('UserDeletionModel', function() {
jest.useRealTimers(); jest.useRealTimers();
}); });
test('should auto-add users for deletion', async function() {
jest.useFakeTimers('modern');
const t0 = new Date('2022-02-22').getTime();
jest.setSystemTime(t0);
await createUser(1);
const user2 = await createUser(2);
await models().user().save({
id: user2.id,
enabled: 0,
disabled_time: t0,
});
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(0);
const t1 = new Date('2022-05-30').getTime();
jest.setSystemTime(t1);
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(1);
const d = (await models().userDeletion().all())[0];
expect(d.user_id).toBe(user2.id);
// Shouldn't add it again if running autoAdd() again
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(1);
jest.useRealTimers();
});
}); });

View File

@ -1,4 +1,4 @@
import { UserDeletion, Uuid } from '../services/database/types'; import { User, UserDeletion, Uuid } from '../services/database/types';
import { errorToString } from '../utils/errors'; import { errorToString } from '../utils/errors';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
@ -7,6 +7,14 @@ export interface AddOptions {
processAccount?: boolean; processAccount?: boolean;
} }
const defaultAddOptions = () => {
const d: AddOptions = {
processAccount: true,
processData: true,
};
return d;
};
export default class UserDeletionModel extends BaseModel<UserDeletion> { export default class UserDeletionModel extends BaseModel<UserDeletion> {
protected get tableName(): string { protected get tableName(): string {
@ -28,8 +36,7 @@ export default class UserDeletionModel extends BaseModel<UserDeletion> {
public async add(userId: Uuid, scheduledTime: number, options: AddOptions = null): Promise<UserDeletion> { public async add(userId: Uuid, scheduledTime: number, options: AddOptions = null): Promise<UserDeletion> {
options = { options = {
processAccount: true, ...defaultAddOptions(),
processData: true,
...options, ...options,
}; };
@ -91,4 +98,26 @@ export default class UserDeletionModel extends BaseModel<UserDeletion> {
.where('id', deletionId); .where('id', deletionId);
} }
public async autoAdd(maxAutoAddedAccounts: number, ttl: number, scheduledTime: number, options: AddOptions = null): Promise<Uuid[]> {
const cutOffTime = Date.now() - ttl;
const disabledUsers: User[] = await this.db('users')
.select(['users.id'])
.leftJoin('user_deletions', 'users.id', 'user_deletions.user_id')
.where('users.enabled', '=', 0)
.where('users.disabled_time', '<', cutOffTime)
.whereNull('user_deletions.user_id') // Only add users not already in the user_deletions table
.limit(maxAutoAddedAccounts);
const userIds = disabledUsers.map(d => d.id);
await this.withTransaction(async () => {
for (const userId of userIds) {
await this.add(userId, scheduledTime, options);
}
}, 'UserDeletionModel::autoAdd');
return userIds;
}
} }

View File

@ -4,6 +4,7 @@ import { Config, Env } from '../utils/types';
import BaseService from './BaseService'; import BaseService from './BaseService';
import { Event, EventType } from './database/types'; import { Event, EventType } from './database/types';
import { Services } from './types'; import { Services } from './types';
import { _ } from '@joplin/lib/locale';
const cron = require('node-cron'); const cron = require('node-cron');
const logger = Logger.create('TaskService'); const logger = Logger.create('TaskService');
@ -17,6 +18,7 @@ export enum TaskId {
DeleteExpiredSessions = 6, DeleteExpiredSessions = 6,
CompressOldChanges = 7, CompressOldChanges = 7,
ProcessUserDeletions = 8, ProcessUserDeletions = 8,
AutoAddDisabledAccountsForDeletion = 9,
} }
export enum RunType { export enum RunType {
@ -24,6 +26,25 @@ export enum RunType {
Manual = 2, Manual = 2,
} }
export const taskIdToLabel = (taskId: TaskId): string => {
const strings: Record<TaskId, string> = {
[TaskId.DeleteExpiredTokens]: _('Delete expired tokens'),
[TaskId.UpdateTotalSizes]: _('Update total sizes'),
[TaskId.HandleOversizedAccounts]: _('Process oversized accounts'),
[TaskId.HandleBetaUserEmails]: 'Process beta user emails',
[TaskId.HandleFailedPaymentSubscriptions]: _('Process failed payment subscriptions'),
[TaskId.DeleteExpiredSessions]: _('Delete expired sessions'),
[TaskId.CompressOldChanges]: _('Compress old changes'),
[TaskId.ProcessUserDeletions]: _('Process user deletions'),
[TaskId.AutoAddDisabledAccountsForDeletion]: _('Auto-add disabled accounts for deletion'),
};
const s = strings[taskId];
if (!s) throw new Error(`No such task: ${taskId}`);
return s;
};
const runTypeToString = (runType: RunType) => { const runTypeToString = (runType: RunType) => {
if (runType === RunType.Scheduled) return 'scheduled'; if (runType === RunType.Scheduled) return 'scheduled';
if (runType === RunType.Manual) return 'manual'; if (runType === RunType.Manual) return 'manual';

View File

@ -1,8 +1,8 @@
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import { Pagination } from '../models/utils/pagination'; import { Pagination } from '../models/utils/pagination';
import { msleep } from '../utils/time'; import { Day, msleep } from '../utils/time';
import BaseService from './BaseService'; import BaseService from './BaseService';
import { UserDeletion, UserFlagType, Uuid } from './database/types'; import { BackupItemType, UserDeletion, UserFlagType, Uuid } from './database/types';
const logger = Logger.create('UserDeletionService'); const logger = Logger.create('UserDeletionService');
@ -59,6 +59,21 @@ export default class UserDeletionService extends BaseService {
private async deleteUserAccount(userId: Uuid, _options: DeletionJobOptions = null) { private async deleteUserAccount(userId: Uuid, _options: DeletionJobOptions = null) {
logger.info(`Deleting user account: ${userId}`); logger.info(`Deleting user account: ${userId}`);
const user = await this.models.user().load(userId);
if (!user) throw new Error(`No such user: ${userId}`);
const flags = await this.models.userFlag().allByUserId(userId);
await this.models.backupItem().add(
BackupItemType.UserAccount,
user.email,
JSON.stringify({
user,
flags,
}),
userId
);
await this.models.userFlag().add(userId, UserFlagType.UserDeletionInProgress); await this.models.userFlag().add(userId, UserFlagType.UserDeletionInProgress);
await this.models.session().deleteByUserId(userId); await this.models.session().deleteByUserId(userId);
@ -93,6 +108,24 @@ export default class UserDeletionService extends BaseService {
logger.info('Completed user deletion: ', deletion.id); logger.info('Completed user deletion: ', deletion.id);
} }
public async autoAddForDeletion() {
const addedUserIds = await this.models.userDeletion().autoAdd(
10,
this.config.USER_DATA_AUTO_DELETE_AFTER_DAYS * Day,
3 * Day,
{
processAccount: true,
processData: true,
}
);
if (addedUserIds.length) {
logger.info(`autoAddForDeletion: Queued ${addedUserIds.length} users for deletions: ${addedUserIds.join(', ')}`);
} else {
logger.info('autoAddForDeletion: No users were queued for deletion');
}
}
public async processNextDeletionJob() { public async processNextDeletionJob() {
const deletion = await this.models.userDeletion().next(); const deletion = await this.models.userDeletion().next();
if (!deletion) return; if (!deletion) return;

View File

@ -1,5 +1,5 @@
import { Models } from '../models/factory'; import { Models } from '../models/factory';
import TaskService, { Task, TaskId } from '../services/TaskService'; import TaskService, { Task, TaskId, taskIdToLabel } from '../services/TaskService';
import { Services } from '../services/types'; import { Services } from '../services/types';
import { Config, Env } from './types'; import { Config, Env } from './types';
@ -9,28 +9,28 @@ export default function(env: Env, models: Models, config: Config, services: Serv
let tasks: Task[] = [ let tasks: Task[] = [
{ {
id: TaskId.DeleteExpiredTokens, id: TaskId.DeleteExpiredTokens,
description: 'Delete expired tokens', description: taskIdToLabel(TaskId.DeleteExpiredTokens),
schedule: '0 */6 * * *', schedule: '0 */6 * * *',
run: (models: Models) => models.token().deleteExpiredTokens(), run: (models: Models) => models.token().deleteExpiredTokens(),
}, },
{ {
id: TaskId.UpdateTotalSizes, id: TaskId.UpdateTotalSizes,
description: 'Update total sizes', description: taskIdToLabel(TaskId.UpdateTotalSizes),
schedule: '0 * * * *', schedule: '0 * * * *',
run: (models: Models) => models.item().updateTotalSizes(), run: (models: Models) => models.item().updateTotalSizes(),
}, },
{ {
id: TaskId.CompressOldChanges, id: TaskId.CompressOldChanges,
description: 'Compress old changes', description: taskIdToLabel(TaskId.CompressOldChanges),
schedule: '0 0 */2 * *', schedule: '0 0 */2 * *',
run: (models: Models) => models.change().compressOldChanges(), run: (models: Models) => models.change().compressOldChanges(),
}, },
{ {
id: TaskId.ProcessUserDeletions, id: TaskId.ProcessUserDeletions,
description: 'Process user deletions', description: taskIdToLabel(TaskId.ProcessUserDeletions),
schedule: '0 */6 * * *', schedule: '0 */6 * * *',
run: (_models: Models, services: Services) => services.userDeletion.runMaintenance(), run: (_models: Models, services: Services) => services.userDeletion.runMaintenance(),
}, },
@ -41,30 +41,39 @@ export default function(env: Env, models: Models, config: Config, services: Serv
// the UpdateTotalSizes task being run. // the UpdateTotalSizes task being run.
{ {
id: TaskId.HandleOversizedAccounts, id: TaskId.HandleOversizedAccounts,
description: 'Process oversized accounts', description: taskIdToLabel(TaskId.HandleOversizedAccounts),
schedule: '30 */2 * * *', schedule: '30 */2 * * *',
run: (models: Models) => models.user().handleOversizedAccounts(), run: (models: Models) => models.user().handleOversizedAccounts(),
}, },
// { // {
// id: TaskId.DeleteExpiredSessions, // id: TaskId.DeleteExpiredSessions,
// description: 'Delete expired sessions', // description: taskIdToLabel(TaskId.DeleteExpiredSessions),
// schedule: '0 */6 * * *', // schedule: '0 */6 * * *',
// run: (models: Models) => models.session().deleteExpiredSessions(), // run: (models: Models) => models.session().deleteExpiredSessions(),
// }, // },
]; ];
if (config.USER_DATA_AUTO_DELETE_ENABLED) {
tasks.push({
id: TaskId.AutoAddDisabledAccountsForDeletion,
description: taskIdToLabel(TaskId.AutoAddDisabledAccountsForDeletion),
schedule: '0 14 * * *',
run: (_models: Models, services: Services) => services.userDeletion.autoAddForDeletion(),
});
}
if (config.isJoplinCloud) { if (config.isJoplinCloud) {
tasks = tasks.concat([ tasks = tasks.concat([
{ {
id: TaskId.HandleBetaUserEmails, id: TaskId.HandleBetaUserEmails,
description: 'Process beta user emails', description: taskIdToLabel(TaskId.HandleBetaUserEmails),
schedule: '0 12 * * *', schedule: '0 12 * * *',
run: (models: Models) => models.user().handleBetaUserEmails(), run: (models: Models) => models.user().handleBetaUserEmails(),
}, },
{ {
id: TaskId.HandleFailedPaymentSubscriptions, id: TaskId.HandleFailedPaymentSubscriptions,
description: 'Process failed payment subscriptions', description: taskIdToLabel(TaskId.HandleFailedPaymentSubscriptions),
schedule: '0 13 * * *', schedule: '0 13 * * *',
run: (models: Models) => models.user().handleFailedPaymentSubscriptions(), run: (models: Models) => models.user().handleFailedPaymentSubscriptions(),
}, },

View File

@ -7,7 +7,7 @@ import { Account } from '../models/UserModel';
import { Services } from '../services/types'; import { Services } from '../services/types';
import { Routers } from './routeUtils'; import { Routers } from './routeUtils';
import { DbConnection } from '../db'; import { DbConnection } from '../db';
import { MailerSecurity } from '../env'; import { EnvVariables, MailerSecurity } from '../env';
export enum Env { export enum Env {
Dev = 'dev', Dev = 'dev',
@ -130,7 +130,7 @@ export interface StorageDriverConfig {
bucket?: string; bucket?: string;
} }
export interface Config { export interface Config extends EnvVariables {
appVersion: string; appVersion: string;
appName: string; appName: string;
env: Env; env: Env;