Skip to content

Commit

Permalink
refactor: move export generation logic to v3 controller, GET/HEAD rou…
Browse files Browse the repository at this point in the history
…tes for exports

re: #10384
  • Loading branch information
julianlam committed Aug 22, 2022
1 parent 0cda5aa commit d057051
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 46 deletions.
17 changes: 7 additions & 10 deletions public/src/client/account/consent.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';


define('forum/account/consent', ['forum/account/header', 'alerts'], function (header, alerts) {
define('forum/account/consent', ['forum/account/header', 'alerts', 'api'], function (header, alerts, api) {
const Consent = {};

Consent.init = function () {
Expand All @@ -17,18 +17,15 @@ define('forum/account/consent', ['forum/account/header', 'alerts'], function (he
});
});

handleExport($('[data-action="export-profile"]'), 'user.exportProfile', '[[user:consent.export-profile-success]]');
handleExport($('[data-action="export-posts"]'), 'user.exportPosts', '[[user:consent.export-posts-success]]');
handleExport($('[data-action="export-uploads"]'), 'user.exportUploads', '[[user:consent.export-uploads-success]]');
handleExport($('[data-action="export-profile"]'), 'profile', '[[user:consent.export-profile-success]]');
handleExport($('[data-action="export-posts"]'), 'posts', '[[user:consent.export-posts-success]]');
handleExport($('[data-action="export-uploads"]'), 'uploads', '[[user:consent.export-uploads-success]]');

function handleExport(el, method, success) {
function handleExport(el, type, success) {
el.on('click', function () {
socket.emit(method, { uid: ajaxify.data.uid }, function (err) {
if (err) {
return alerts.error(err);
}
api.post(`/users/${ajaxify.data.uid}/exports/${type}`).then(() => {
alerts.success(success);
});
}).catch(alerts.error);
});
}
};
Expand Down
35 changes: 35 additions & 0 deletions src/api/users.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const validator = require('validator');
const winston = require('winston');

const db = require('../database');
const user = require('../user');
Expand Down Expand Up @@ -441,3 +442,37 @@ usersAPI.changePicture = async (caller, data) => {
'icon:bgColor': data.bgColor,
}, ['picture', 'icon:bgColor']);
};

usersAPI.generateExport = async (caller, { uid, type }) => {
const count = await db.incrObjectField('locks', `export:${uid}${type}`);
if (count > 1) {
throw new Error('[[error:already-exporting]]');
}

const child = require('child_process').fork(`./src/user/jobs/export-${type}.js`, [], {
env: process.env,
});
child.send({ uid });
child.on('error', async (err) => {
winston.error(err.stack);
await db.deleteObjectField('locks', `export:${uid}${type}`);
});
child.on('exit', async () => {
await db.deleteObjectField('locks', `export:${uid}${type}`);
const userData = await user.getUserFields(uid, ['username', 'userslug']);
const { displayname } = userData;
const n = await notifications.create({
bodyShort: `[[notifications:${type}-exported, ${displayname}]]`,
path: `/api/user/${userData.userslug}/export/${type}`,
nid: `${type}:export:${uid}`,
from: uid,
});
await notifications.push(n, [caller.uid]);
await events.log({
type: `export:${type}`,
uid: caller.uid,
targetUid: uid,
ip: caller.ip,
});
});
};
4 changes: 4 additions & 0 deletions src/controllers/user.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const path = require('path');
const winston = require('winston');

const user = require('../user');
const privileges = require('../privileges');
Expand Down Expand Up @@ -90,7 +91,10 @@ userController.exportProfile = async function (req, res, next) {
sendExport(`${res.locals.uid}_profile.json`, 'application/json', res, next);
};

// DEPRECATED; Remove in NodeBB v3.0.0
function sendExport(filename, type, res, next) {
winston.warn(`[users/export] Access via page API is deprecated, use GET /api/v3/users/:uid/exports/:type instead.`);

res.sendFile(filename, {
root: path.join(__dirname, '../../build/export'),
headers: {
Expand Down
58 changes: 58 additions & 0 deletions src/controllers/write/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

const util = require('util');
const nconf = require('nconf');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;

const db = require('../../database');
const api = require('../../api');
Expand All @@ -15,6 +18,12 @@ const helpers = require('../helpers');

const Users = module.exports;

const exportMetadata = new Map([
['posts', ['csv', 'text/csv']],
['uploads', ['zip', 'application/zip']],
['profile', ['json', 'application/json']],
]);

const hasAdminPrivilege = async (uid, privilege) => {
const ok = await privileges.admin.can(`admin:${privilege}`, uid);
if (!ok) {
Expand Down Expand Up @@ -296,3 +305,52 @@ Users.confirmEmail = async (req, res) => {
helpers.formatApiResponse(404, res);
}
};

const prepareExport = async (req, res) => {
const [extension] = exportMetadata.get(req.params.type);
const filename = `${req.params.uid}_${req.params.type}.${extension}`;
try {
const stat = await fs.stat(path.join(__dirname, '../../../build/export', filename));
const modified = new Date(stat.mtimeMs);
res.set('Last-Modified', modified.toUTCString());
res.set('ETag', `"${crypto.createHash('md5').update(String(stat.mtimeMs)).digest('hex')}"`);
res.status(204);
return true;
} catch (e) {
res.status(404);
return false;
}
};

Users.checkExportByType = async (req, res) => {
await prepareExport(req, res);
res.end();
};

Users.getExportByType = async (req, res) => {
const [extension, mime] = exportMetadata.get(req.params.type);
const filename = `${req.params.uid}_${req.params.type}.${extension}`;

const exists = await prepareExport(req, res);
if (!exists) {
return res.end();
}

res.status(200);
res.sendFile(filename, {
root: path.join(__dirname, '../../../build/export'),
headers: {
'Content-Type': mime,
'Content-Disposition': `attachment; filename=${filename}`,
},
}, (err) => {
if (err) {
throw err;
}
});
};

Users.generateExportsByType = async (req, res) => {
await api.users.generateExport(req, req.params);
helpers.formatApiResponse(202, res);
};
4 changes: 4 additions & 0 deletions src/routes/write/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ function authenticatedRoutes() {
setupApiRoute(router, 'get', '/:uid/emails/:email', [...middlewares, middleware.assert.user], controllers.write.users.getEmail);
setupApiRoute(router, 'post', '/:uid/emails/:email/confirm', [...middlewares, middleware.assert.user], controllers.write.users.confirmEmail);

setupApiRoute(router, 'head', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.checkExportByType);
setupApiRoute(router, 'get', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.getExportByType);
setupApiRoute(router, 'post', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.generateExportsByType);

// Shorthand route to access user routes by userslug
router.all('/+bySlug/:userslug*?', [], controllers.write.users.redirectBySlug);
}
Expand Down
42 changes: 6 additions & 36 deletions src/socket.io/user/profile.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
'use strict';

const winston = require('winston');

const user = require('../../user');
const events = require('../../events');
const notifications = require('../../notifications');
const privileges = require('../../privileges');
const db = require('../../database');
const plugins = require('../../plugins');

const sockets = require('..');
const api = require('../../api');

module.exports = function (SocketUser) {
SocketUser.updateCover = async function (socket, data) {
if (!socket.uid) {
Expand Down Expand Up @@ -64,6 +62,8 @@ module.exports = function (SocketUser) {
};

async function doExport(socket, data, type) {
sockets.warnDeprecated(socket, 'POST /api/v3/users/:uid/exports/:type');

if (!socket.uid) {
throw new Error('[[error:invalid-uid]]');
}
Expand All @@ -74,36 +74,6 @@ module.exports = function (SocketUser) {

await user.isAdminOrSelf(socket.uid, data.uid);

const count = await db.incrObjectField('locks', `export:${data.uid}${type}`);
if (count > 1) {
throw new Error('[[error:already-exporting]]');
}

const child = require('child_process').fork(`./src/user/jobs/export-${type}.js`, [], {
env: process.env,
});
child.send({ uid: data.uid });
child.on('error', async (err) => {
winston.error(err.stack);
await db.deleteObjectField('locks', `export:${data.uid}${type}`);
});
child.on('exit', async () => {
await db.deleteObjectField('locks', `export:${data.uid}${type}`);
const userData = await user.getUserFields(data.uid, ['username', 'userslug']);
const { displayname } = userData;
const n = await notifications.create({
bodyShort: `[[notifications:${type}-exported, ${displayname}]]`,
path: `/api/user/${userData.userslug}/export/${type}`,
nid: `${type}:export:${data.uid}`,
from: data.uid,
});
await notifications.push(n, [socket.uid]);
await events.log({
type: `export:${type}`,
uid: socket.uid,
targetUid: data.uid,
ip: socket.ip,
});
});
api.users.generateExport(socket, { type, ...data });
}
};

0 comments on commit d057051

Please sign in to comment.