diff --git a/migrations/20210429194328-users-add-avatar.js b/migrations/20210429194328-users-add-avatar.js new file mode 100644 index 000000000..585764ce8 --- /dev/null +++ b/migrations/20210429194328-users-add-avatar.js @@ -0,0 +1,33 @@ +'use strict'; + +const async = require('async'), + fs = require('fs'), + path = require('path'); + +const AVATAR_DIR = '/home/yellowtent/boxdata/profileicons'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE users ADD COLUMN avatar MEDIUMBLOB', function (error) { + if (error) return callback(error); + + fs.readdir(AVATAR_DIR, function (error, filenames) { + if (error && error.code === 'ENOENT') return callback(); + if (error) return callback(error); + + async.eachSeries(filenames, function (filename, iteratorCallback) { + const avatar = fs.readFileSync(path.join(AVATAR_DIR, filename)); + const userId = filename; + + db.runSql('UPDATE users SET avatar=? WHERE id=?', [ avatar, userId ], iteratorCallback); + }, callback); + }); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE users DROP COLUMN avatar', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/schema.sql b/migrations/schema.sql index b2d51edc1..54142db8c 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -6,7 +6,7 @@ #### Strict mode is enabled #### VARCHAR - stored as part of table row (use for strings) #### TEXT - stored offline from table row (use for strings) -#### BLOB - stored offline from table row (use for binary data) +#### BLOB (64KB), MEDIUMBLOB (16MB), LONGBLOB (4GB) - stored offline from table row (use for binary data) #### https://dev.mysql.com/doc/refman/5.0/en/storage-requirements.html #### Times are stored in the database in UTC. And precision is seconds @@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS users( resetToken VARCHAR(128) DEFAULT "", resetTokenCreationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, active BOOLEAN DEFAULT 1, + avatar MEDIUMBLOB, PRIMARY KEY(id)); diff --git a/runTests b/runTests index 7ca26ebe0..7b07fed74 100755 --- a/runTests +++ b/runTests @@ -22,7 +22,7 @@ fi mkdir -p ${DATA_DIR} cd ${DATA_DIR} mkdir -p appsdata -mkdir -p boxdata/profileicons boxdata/appicons boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com boxdata/sftp/ssh boxdata/firewall +mkdir -p boxdata/appicons boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com boxdata/sftp/ssh boxdata/firewall mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks sudo mkdir -p /mnt/cloudron-test-music /media/cloudron-test-music # volume test diff --git a/setup/start.sh b/setup/start.sh index 9230bd941..0860d8353 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -64,7 +64,6 @@ mkdir -p "${PLATFORM_DATA_DIR}/update" mkdir -p "${BOX_DATA_DIR}/appicons" mkdir -p "${BOX_DATA_DIR}/firewall" -mkdir -p "${BOX_DATA_DIR}/profileicons" mkdir -p "${BOX_DATA_DIR}/certs" mkdir -p "${BOX_DATA_DIR}/acme" # acme keys mkdir -p "${BOX_DATA_DIR}/mail/dkim" diff --git a/src/paths.js b/src/paths.js index 0cd9c7000..d6d846a3c 100644 --- a/src/paths.js +++ b/src/paths.js @@ -42,7 +42,6 @@ exports = module.exports = { // this is not part of appdata because an icon may be set before install APP_ICONS_DIR: path.join(baseDir(), 'boxdata/appicons'), - PROFILE_ICONS_DIR: path.join(baseDir(), 'boxdata/profileicons'), MAIL_DATA_DIR: path.join(baseDir(), 'boxdata/mail'), SFTP_KEYS_DIR: path.join(baseDir(), 'boxdata/sftp/ssh'), ACME_ACCOUNT_KEY_FILE: path.join(baseDir(), 'boxdata/acme/acme.key'), diff --git a/src/routes/profile.js b/src/routes/profile.js index c5e0b179d..74bbc3321 100644 --- a/src/routes/profile.js +++ b/src/routes/profile.js @@ -18,8 +18,9 @@ var assert = require('assert'), BoxError = require('../boxerror.js'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, - users = require('../users.js'), + safe = require('safetydance'), settings = require('../settings.js'), + users = require('../users.js'), _ = require('underscore'); function authorize(req, res, next) { @@ -37,17 +38,21 @@ function authorize(req, res, next) { function get(req, res, next) { assert.strictEqual(typeof req.user, 'object'); - next(new HttpSuccess(200, { - id: req.user.id, - username: req.user.username, - email: req.user.email, - fallbackEmail: req.user.fallbackEmail, - displayName: req.user.displayName, - twoFactorAuthenticationEnabled: req.user.twoFactorAuthenticationEnabled, - role: req.user.role, - source: req.user.source, - avatarUrl: users.getAvatarUrlSync(req.user) - })); + users.getAvatarUrl(req.user, function (error, avatarUrl) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { + id: req.user.id, + username: req.user.username, + email: req.user.email, + fallbackEmail: req.user.fallbackEmail, + displayName: req.user.displayName, + twoFactorAuthenticationEnabled: req.user.twoFactorAuthenticationEnabled, + role: req.user.role, + source: req.user.source, + avatarUrl + })); + }); } function update(req, res, next) { @@ -72,7 +77,10 @@ function setAvatar(req, res, next) { if (!req.files.avatar) return next(new HttpError(400, 'avatar is missing')); - users.setAvatar(req.user.id, req.files.avatar.path, function (error) { + const avatar = safe.fs.readFileSync(req.files.avatar.path); + if (!avatar) return next(BoxError.toHttpError(new BoxError(BoxError.FS_ERROR, safe.error.message))); + + users.setAvatar(req.user.id, avatar, function (error) { if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(202, {})); @@ -82,17 +90,21 @@ function setAvatar(req, res, next) { function clearAvatar(req, res, next) { assert.strictEqual(typeof req.user, 'object'); - users.clearAvatar(req.user.id, function (error) { + users.setAvatar(req.user.id, null, function (error) { if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(202, {})); }); } -function getAvatar(req, res) { +function getAvatar(req, res, next) { assert.strictEqual(typeof req.params.identifier, 'string'); - res.sendFile(users.getAvatarFileSync(req.params.identifier)); + users.getAvatar(req.params.identifier, function (error, avatar) { + if (error) return next(BoxError.toHttpError(error)); + + res.send(avatar); + }); } function changePassword(req, res, next) { diff --git a/src/routes/test/users-test.js b/src/routes/test/users-test.js index 62ab7296d..3bd1acf38 100644 --- a/src/routes/test/users-test.js +++ b/src/routes/test/users-test.js @@ -967,8 +967,8 @@ describe('Users API', function () { expect(result.body.role).to.equal('user'); done(); - }); - }); + }); + }); }); }); }); diff --git a/src/routes/users.js b/src/routes/users.js index 7f0a0fe2d..4752c92f5 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -11,8 +11,6 @@ exports = module.exports = { createInvite, sendInvite, setGroups, - setAvatar, - clearAvatar, makeOwner, disableTwoFactorAuthentication, @@ -210,28 +208,6 @@ function changePassword(req, res, next) { }); } -function setAvatar(req, res, next) { - assert.strictEqual(typeof req.resource, 'object'); - - if (!req.files.avatar) return next(new HttpError(400, 'avatar is missing')); - - users.setAvatar(req.resource.id, req.files.avatar.path, function (error) { - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(202, {})); - }); -} - -function clearAvatar(req, res, next) { - assert.strictEqual(typeof req.resource, 'object'); - - users.clearAvatar(req.resource.id, function (error) { - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(202, {})); - }); -} - // This route transfers ownership from token user to user specified in path param function makeOwner(req, res, next) { assert.strictEqual(typeof req.resource, 'object'); diff --git a/src/server.js b/src/server.js index 98368b0f0..6e607df39 100644 --- a/src/server.js +++ b/src/server.js @@ -178,8 +178,6 @@ function initializeExpressSync() { router.post('/api/v1/users/:userId/make_owner', json, token, authorizeOwner, routes.users.load, routes.users.makeOwner); router.post('/api/v1/users/:userId/send_invite', json, token, authorizeUserManager, routes.users.load, routes.users.sendInvite); router.post('/api/v1/users/:userId/create_invite', json, token, authorizeUserManager, routes.users.load, routes.users.createInvite); - router.post('/api/v1/users/:userId/avatar', json, token, authorizeUserManager, routes.users.load, multipart, routes.users.setAvatar); - router.del ('/api/v1/users/:userId/avatar', token, authorizeUserManager, routes.users.load, routes.users.clearAvatar); router.post('/api/v1/users/:userId/twofactorauthentication_disable', json, token, authorizeUserManager, routes.users.load, routes.users.disableTwoFactorAuthentication); // Group management diff --git a/src/userdb.js b/src/userdb.js index 8d27c2e04..5590e63da 100644 --- a/src/userdb.js +++ b/src/userdb.js @@ -13,6 +13,8 @@ exports = module.exports = { del, update, count, + getAvatar, + setAvatar, addAppPassword, getAppPasswords, @@ -25,13 +27,13 @@ exports = module.exports = { var assert = require('assert'), BoxError = require('./boxerror.js'), database = require('./database.js'), - debug = require('debug')('box:userdb'), mysql = require('mysql'); -var USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'createdAt', 'resetToken', 'displayName', +// the avatar field is special and not added here to reduce response sizes +const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'createdAt', 'resetToken', 'displayName', 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime' ].join(','); -var APP_PASSWORD_FIELDS = [ 'id', 'name', 'userId', 'identifier', 'hashedPassword', 'creationTime' ].join(','); +const APP_PASSWORD_FIELDS = [ 'id', 'name', 'userId', 'identifier', 'hashedPassword', 'creationTime' ].join(','); function postProcess(result) { assert.strictEqual(typeof result, 'object'); @@ -209,8 +211,6 @@ function getByAccessToken(accessToken, callback) { assert.strictEqual(typeof accessToken, 'string'); assert.strictEqual(typeof callback, 'function'); - debug('getByAccessToken: ' + accessToken); - database.query('SELECT ' + USERS_FIELDS + ' FROM users, tokens WHERE tokens.accessToken = ?', [ accessToken ], function (error, result) { if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); if (result.length === 0) return callback(new BoxError(BoxError.NOT_FOUND, 'User not found')); @@ -322,3 +322,27 @@ function delAppPassword(id, callback) { }); } +function getAvatar(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT avatar FROM users WHERE id = ?', [ id ], function (error, result) { + if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); + if (result.length === 0) return callback(new BoxError(BoxError.NOT_FOUND, 'User not found')); + + callback(null, result[0].avatar); + }); +} + +function setAvatar(id, avatar, callback) { + assert.strictEqual(typeof id, 'string'); + assert(avatar === null || typeof Buffer.isBuffer(avatar)); + assert.strictEqual(typeof callback, 'function'); + + database.query('UPDATE users SET avatar=? WHERE id = ?', [ avatar, id ], function (error, result) { + if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); + if (result.length === 0) return callback(new BoxError(BoxError.NOT_FOUND, 'User not found')); + + callback(null); + }); +} diff --git a/src/users.js b/src/users.js index 915e73255..1ebdc9ecc 100644 --- a/src/users.js +++ b/src/users.js @@ -31,10 +31,9 @@ exports = module.exports = { sendPasswordResetByIdentifier, setupAccount, - getAvatarUrlSync, - getAvatarFileSync, + getAvatarUrl, setAvatar, - clearAvatar, + getAvatar, count, @@ -62,11 +61,9 @@ let assert = require('assert'), debug = require('debug')('box:user'), eventlog = require('./eventlog.js'), externalLdap = require('./externalldap.js'), - fs = require('fs'), groups = require('./groups.js'), hat = require('./hat.js'), mailer = require('./mailer.js'), - path = require('path'), paths = require('./paths.js'), qrcode = require('qrcode'), safe = require('safetydance'), @@ -816,38 +813,34 @@ function delAppPassword(id, callback) { }); } -function getAvatarFileSync(id) { - assert.strictEqual(typeof id, 'string'); - - return path.join(paths.PROFILE_ICONS_DIR, id); -} - -function getAvatarUrlSync(user) { +function getAvatarUrl(user, callback) { assert.strictEqual(typeof user, 'object'); - - if (fs.existsSync(path.join(paths.PROFILE_ICONS_DIR, user.id))) return `${settings.adminOrigin()}/api/v1/profile/avatar/${user.id}`; - - const emailHash = require('crypto').createHash('md5').update(user.email).digest('hex'); - return `https://www.gravatar.com/avatar/${emailHash}.jpg`; -} - -function setAvatar(id, filename, callback) { - assert.strictEqual(typeof id, 'string'); - assert.strictEqual(typeof filename, 'string'); assert.strictEqual(typeof callback, 'function'); - // rename() was failing on some servers with EXDEV - fs.copyFile(filename, path.join(paths.PROFILE_ICONS_DIR, id), function (error) { - if (error) return callback(new BoxError(BoxError.FS_ERROR, error.message)); + userdb.getAvatar(user.id, function (error, avatar) { + if (error) return callback(error); + if (avatar) return callback(null, `${settings.adminOrigin()}/api/v1/profile/avatar/${user.id}`); - fs.unlink(filename, () => callback()); // ignore any unlink error + const emailHash = require('crypto').createHash('md5').update(user.email).digest('hex'); + return callback(null, `https://www.gravatar.com/avatar/${emailHash}.jpg`); }); } -function clearAvatar(id, callback) { +function getAvatar(id, callback) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof callback, 'function'); - safe.fs.unlinkSync(path.join(paths.PROFILE_ICONS_DIR, id)); - callback(); + userdb.getAvatar(id, function (error, avatar) { + if (error) return callback(error); + + return callback(null, avatar); + }); +} + +function setAvatar(id, avatar, callback) { + assert.strictEqual(typeof id, 'string'); + assert(avatar === null || Buffer.isBuffer(avatar)); + assert.strictEqual(typeof callback, 'function'); + + userdb.setAvatar(id, avatar, callback); }