You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
445 lines
14 KiB
445 lines
14 KiB
// reeeper/job/archive-user-local.js
|
|
// Copyright (C) 2022 DTP Technologies, LLC
|
|
// License: Apache-2.0
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
const util = require('util');
|
|
const execFile = util.promisify(require('child_process').execFile);
|
|
|
|
const mime = require('mime');
|
|
|
|
const mongoose = require('mongoose');
|
|
const User = mongoose.model('User');
|
|
|
|
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib'));
|
|
|
|
/**
|
|
* A job to archive and ban a User (local).
|
|
*
|
|
* 1. Immediately disable the specified User
|
|
* 2. Create a .zip file of the User's content on storage
|
|
* 3. Creates a UserArchive record for the file and User
|
|
* 4. Ban the User (removes all of the User's content)
|
|
* 5. Remove the User record from the database
|
|
*/
|
|
class ArchiveUserLocalJob extends SiteWorkerProcess {
|
|
|
|
static get COMPONENT ( ) {
|
|
return {
|
|
logId: 'wrk:reeeper:archive-user-local:job',
|
|
index: 'archiveUserLocalJob',
|
|
className: 'ArchiveUserLocalJob',
|
|
};
|
|
}
|
|
|
|
constructor (worker) {
|
|
super(worker, ArchiveUserLocalJob.COMPONENT);
|
|
this.jobs = new Set();
|
|
}
|
|
|
|
async start ( ) {
|
|
await super.start();
|
|
|
|
this.queue = await this.getJobQueue('reeeper', this.dtp.config.jobQueues.reeeper);
|
|
|
|
this.log.info('registering job processor', { queue: this.queue.name });
|
|
this.queue.process('archive-user-local', 1, this.processArchiveUserLocal.bind(this));
|
|
}
|
|
|
|
async stop ( ) {
|
|
try {
|
|
if (this.queue) {
|
|
this.log.info('halting job queue', { jobCount: this.jobs.size });
|
|
await this.queue.pause(true, false);
|
|
delete this.queue;
|
|
}
|
|
} catch (error) {
|
|
this.log.error('failed to halt job queue', { error });
|
|
// fall through
|
|
} finally {
|
|
await super.stop();
|
|
}
|
|
}
|
|
|
|
async processArchiveUserLocal (job) {
|
|
const { user: userService } = this.dtp.services;
|
|
try {
|
|
job.data.archivePath = path.join('/tmp', this.dtp.pkg.name, 'archive-user-local');
|
|
this.jobs.add(job);
|
|
|
|
job.data.userId = mongoose.Types.ObjectId(job.data.userId);
|
|
job.data.user = await userService.getLocalUserAccount(job.data.userId);
|
|
|
|
job.data.workPath = path.join(job.data.archivePath, job.data.userId.toString());
|
|
await fs.promises.mkdir(job.data.workPath, { recursive: true });
|
|
|
|
/*
|
|
* Save the User account data
|
|
*/
|
|
await this.archiveUserData(job);
|
|
|
|
/*
|
|
* Disable the User account (which destroys their session and cookie(s))
|
|
*/
|
|
await this.disableUser(job);
|
|
|
|
/*
|
|
* Archive the User's content to the workPath on the local file system.
|
|
*/
|
|
await this.archiveUserChat(job);
|
|
await this.archiveUserComments(job);
|
|
await this.archiveUserStickers(job);
|
|
await this.archiveUserImages(job);
|
|
await this.archiveUserAttachments(job);
|
|
|
|
/*
|
|
* Create the .zip file archive, upload it to storage, and create the
|
|
* UserArchive record.
|
|
*/
|
|
await this.createArchiveFile(job);
|
|
|
|
this.log.info('banning user', {
|
|
user: {
|
|
_id: job.data.userId,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
await userService.ban(job.data.user);
|
|
|
|
this.log.info('removing user', {
|
|
user: {
|
|
_id: job.data.userId,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
await User.deleteOne({ _id: job.data.userId });
|
|
} catch (error) {
|
|
this.log.error('failed to archive user', { userId: job.data.userId, error });
|
|
throw error;
|
|
} finally {
|
|
if (job.data.workPath) {
|
|
this.log.info('cleaning up work directory');
|
|
await fs.promises.rm(job.data.workPath, { force: true, recursive: true });
|
|
|
|
delete job.data.workPath;
|
|
}
|
|
this.jobs.delete(job);
|
|
this.log.info('job complete', { job: job.id });
|
|
}
|
|
}
|
|
|
|
async archiveUserData (job) {
|
|
// fetch the entire User record (all fields)
|
|
job.data.fullUser = await User
|
|
.findOne({ _id: job.data.user._id })
|
|
.select('+email +passwordSalt +password +flags +permissions +optIn')
|
|
.lean();
|
|
if (!job.data.fullUser) {
|
|
throw new Error('user does not exist');
|
|
}
|
|
|
|
const userFilename = path.join(job.data.workPath, `user-${job.data.user._id}.json`);
|
|
await fs.promises.writeFile(userFilename, JSON.stringify(job.data.fullUser, null, 2));
|
|
}
|
|
|
|
async disableUser (job) {
|
|
this.log.info('disabling local User account', {
|
|
user: {
|
|
_id: job.data.userId,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
await User.updateOne(
|
|
{ _id: job.data.user._id },
|
|
{
|
|
$set: {
|
|
'flags.isAdmin': false,
|
|
'flags.isModerator': false,
|
|
'flags.isEmailVerified': false,
|
|
'permissions.canLogin': false,
|
|
'permissions.canChat': false,
|
|
'permissions.canComment': false,
|
|
'permissions.canReport': false,
|
|
'optIn.system': false,
|
|
'optIn.marketing': false,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async archiveUserChat (job) {
|
|
const ChatMessage = mongoose.model('ChatMessage');
|
|
const ChatRoom = mongoose.model('ChatRoom');
|
|
|
|
job.data.chatPath = path.join(job.data.workPath, 'chat');
|
|
await fs.promises.mkdir(job.data.chatPath, { recursive: true });
|
|
|
|
this.log.info('archiving user chat', {
|
|
user: {
|
|
_id: job.data.user._id,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
|
|
await ChatRoom
|
|
.find({ owner: job.data.user._id })
|
|
|
|
.lean()
|
|
.cursor()
|
|
.eachAsync(async (room) => {
|
|
const roomFilename = path.join(job.data.workPath, 'chat', `room-${room._id}`);
|
|
await fs.promises.writeFile(roomFilename, JSON.stringify(room, null, 2));
|
|
});
|
|
|
|
await ChatMessage
|
|
.find({ author: job.data.user._id })
|
|
.lean()
|
|
.cursor()
|
|
.eachAsync(async (message) => {
|
|
const messageFilename = path.join(job.data.workPath, 'chat', `message-${message._id}.json`);
|
|
await fs.promises.writeFile(messageFilename, JSON.stringify(message, null, 2));
|
|
});
|
|
}
|
|
|
|
async archiveUserComments (job) {
|
|
const Comment = mongoose.model('Comment');
|
|
|
|
job.data.commentPath = path.join(job.data.workPath, 'comments');
|
|
await fs.promises.mkdir(job.data.commentPath, { recursive: true });
|
|
|
|
this.log.info('archiving user comments', {
|
|
user: {
|
|
_id: job.data.user._id,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
|
|
await Comment
|
|
.find({ author: job.data.userId })
|
|
.cursor()
|
|
.eachAsync(async (comment) => {
|
|
const commentFilename = path.join(job.data.commentPath, `comment-${comment._id}.json`);
|
|
await fs.promises.writeFile(commentFilename, JSON.stringify(comment, null, 2));
|
|
});
|
|
}
|
|
|
|
async archiveUserStickers (job) {
|
|
const Sticker = mongoose.model('Sticker');
|
|
const { minio: minioService } = this.dtp.services;
|
|
|
|
job.data.stickerPath = path.join(job.data.workPath, 'stickers');
|
|
await fs.promises.mkdir(job.data.stickerPath, { recursive: true });
|
|
|
|
job.data.stickerMediaPath = path.join(job.data.stickerPath, 'media');
|
|
await fs.promises.mkdir(job.data.stickerMediaPath, { recursive: true });
|
|
|
|
this.log.info('archiving user stickers', {
|
|
user: {
|
|
_id: job.data.user._id,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
|
|
await Sticker
|
|
.find({ owner: job.data.userId })
|
|
.cursor()
|
|
.eachAsync(async (sticker) => {
|
|
const stickerFilename = path.join(job.data.stickerPath, `sticker-${sticker._id}.json`);
|
|
await fs.promises.writeFile(stickerFilename, JSON.stringify(sticker, null, 2));
|
|
|
|
if (sticker.original && sticker.original.bucket && sticker.orignal.key && sticker.encoded.type) {
|
|
const originalExt = mime.getExtension(sticker.original.type);
|
|
const originalFilename = path.join(job.data.stickerMediaPath, `sticker-${sticker._id}.original.${originalExt}`);
|
|
await minioService.downloadFile({
|
|
bucket: sticker.original.bucket,
|
|
key: sticker.original.key,
|
|
filePath: originalFilename,
|
|
});
|
|
}
|
|
|
|
if (sticker.encoded && sticker.encoded.bucket && sticker.encoded.key && sticker.encoded.type) {
|
|
const encodedExt = mime.getExtension(sticker.encoded.type);
|
|
const encodedFilename = path.join(job.data.stickerMediaPath, `sticker-${sticker._id}.encoded.${encodedExt}`);
|
|
await minioService.downloadFile({
|
|
bucket: sticker.encoded.bucket,
|
|
key: sticker.encoded.key,
|
|
filePath: encodedFilename,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async archiveUserImages (job) {
|
|
const SiteImage = mongoose.model('Image');
|
|
const { image: imageService } = this.dtp.services;
|
|
|
|
job.data.imagePath = path.join(job.data.workPath, 'images');
|
|
await fs.promises.mkdir(job.data.imagePath, { recursive: true });
|
|
|
|
this.log.info('archiving user images', {
|
|
user: {
|
|
_id: job.data.user._id,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
|
|
await SiteImage
|
|
.find({ owner: job.data.user._id })
|
|
.cursor()
|
|
.eachAsync(async (image) => {
|
|
try {
|
|
let imageExt = mime.getExtension(image.type);
|
|
const imageFilename = path.join(job.data.imagePath, `image-${image._id}.${imageExt}`);
|
|
const metadataFilename = path.join(job.data.imagePath, `image-${image._id}.metadata.json`);
|
|
|
|
await imageService.downloadImage(image, imageFilename);
|
|
await fs.promises.writeFile(metadataFilename, JSON.stringify(image.metadata, null, 2));
|
|
|
|
} catch (error) {
|
|
this.log.error('failed to download image', {
|
|
image: { _id: image._id },
|
|
error,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async archiveUserAttachments (job) {
|
|
const Attachment = mongoose.model('Attachment');
|
|
const { minio: minioService } = this.dtp.services;
|
|
|
|
job.data.attachmentPath = path.join(job.data.workPath, 'attachments');
|
|
await fs.promises.mkdir(job.data.attachmentPath, { recursive: true });
|
|
|
|
job.data.originalAttachmentPath = path.join(job.data.attachmentPath, 'original');
|
|
await fs.promises.mkdir(job.data.originalAttachmentPath, { recursive: true });
|
|
|
|
job.data.encodedAttachmentPath = path.join(job.data.attachmentPath, 'encoded');
|
|
await fs.promises.mkdir(job.data.encodedAttachmentPath, { recursive: true });
|
|
|
|
this.log.info('archiving user attachments', {
|
|
user: {
|
|
_id: job.data.user._id,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
|
|
await Attachment
|
|
.find({ owner: job.data.user._id })
|
|
.cursor()
|
|
.eachAsync(async (attachment) => {
|
|
try {
|
|
/*
|
|
* Write the JSON record archive
|
|
*/
|
|
const metadataFilename = path.join(job.data.attachmentPath, `attachment-${attachment._id}.metadata.json`);
|
|
await fs.promises.writeFile(metadataFilename, JSON.stringify(attachment, null, 2));
|
|
|
|
/*
|
|
* Download and save the original file (if present)
|
|
*/
|
|
if (attachment.original && attachment.original.bucket && attachment.original.key) {
|
|
let originalExt = mime.getExtension(attachment.original.mime);
|
|
const originalFilename = path.join(job.data.originalAttachmentPath, `attachment-${attachment._id}.${originalExt}`);
|
|
await minioService.downloadFile({
|
|
bucket: attachment.original.bucket,
|
|
key: attachment.original.key,
|
|
filePath: originalFilename,
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Download and save the encoded file (if present)
|
|
*/
|
|
if (attachment.encoded && attachment.encoded.bucket && attachment.encoded.key) {
|
|
let encodedExt = mime.getExtension(attachment.encoded.mime);
|
|
const encodedFilename = path.join(job.data.encodedAttachmentPath, `attachment-${attachment._id}.${encodedExt}`);
|
|
await minioService.downloadFile({
|
|
bucket: attachment.encoded.bucket,
|
|
key: attachment.encoded.key,
|
|
filePath: encodedFilename,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
this.log.error('failed to archive attachment', {
|
|
attachment: { _id: attachment._id },
|
|
error,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async createArchiveFile (job) {
|
|
const { minio: minioService } = this.dtp.services;
|
|
try {
|
|
job.data.zipFilename = path.join(job.data.archivePath, `user-${job.data.userId}.zip`);
|
|
const zipArgs = [
|
|
'-r', '-9',
|
|
job.data.zipFilename,
|
|
`${job.data.userId}`,
|
|
];
|
|
const options = {
|
|
cwd: job.data.archivePath,
|
|
encoding: 'utf8',
|
|
};
|
|
await execFile('/usr/bin/zip', zipArgs, options);
|
|
|
|
const zipFileStat = await fs.promises.stat(job.data.zipFilename);
|
|
this.log.info('zip archive created', { size: zipFileStat.size });
|
|
|
|
job.data.archiveFile = {
|
|
bucket: process.env.MINIO_ADMIN_BUCKET,
|
|
key: `/user-archive/user-${job.data.userId}.zip`,
|
|
};
|
|
|
|
const response = await minioService.uploadFile({
|
|
bucket: job.data.archiveFile.bucket,
|
|
key: job.data.archiveFile.key,
|
|
filePath: job.data.zipFilename,
|
|
metadata: {
|
|
job: {
|
|
id: job.id,
|
|
},
|
|
user: job.data.user,
|
|
}
|
|
});
|
|
|
|
this.log.info('creating user archive record', { etag: response.etag, size: zipFileStat.size });
|
|
const UserArchive = mongoose.model('UserArchive');
|
|
await UserArchive.create({
|
|
created: job.data.startTime,
|
|
user: {
|
|
_id: job.data.userId,
|
|
username: job.data.user.username,
|
|
email: job.data.fullUser.email,
|
|
},
|
|
archive: {
|
|
bucket: job.data.archiveFile.bucket,
|
|
key: job.data.archiveFile.key,
|
|
etag: response.etag,
|
|
size: zipFileStat.size,
|
|
}
|
|
});
|
|
} catch (error) {
|
|
this.log.error('failed to create archive .zip file', {
|
|
user: {
|
|
_id: job.data.userId,
|
|
username: job.data.user.username,
|
|
},
|
|
});
|
|
throw error;
|
|
} finally {
|
|
try {
|
|
await fs.promises.rm(job.data.zipFilename, { force: true });
|
|
} catch (error) {
|
|
this.log.error('failed to remove temp .zip file', { error });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = ArchiveUserLocalJob;
|