// 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: '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;