diff --git a/CHANGES b/CHANGES index 93ea8b1f3..45098c951 100644 --- a/CHANGES +++ b/CHANGES @@ -2944,4 +2944,5 @@ * multiple docker registries * mail: rename delivered -> sent and received -> saved in event log * graphs: replace collectd with custom collector +* profile: drop gravatar support diff --git a/dashboard/src/models/ProfileModel.js b/dashboard/src/models/ProfileModel.js index f0d1b7473..409f6d8e2 100644 --- a/dashboard/src/models/ProfileModel.js +++ b/dashboard/src/models/ProfileModel.js @@ -59,6 +59,7 @@ function create() { result.body.isAtLeastOwner = [ ROLES.OWNER ].indexOf(result.body.role) !== -1; result.body.backgroundImageUrl = result.body.hasBackgroundImage ? `${API_ORIGIN}/api/v1/profile/background_image?access_token=${accessToken}&bustcache=${Date.now()}` : ''; + result.body.avatarUrl = `${API_ORIGIN}/api/v1/profile/avatar/${result.body.id}`; profileCached = result.body; diff --git a/dashboard/src/views/ProfileView.vue b/dashboard/src/views/ProfileView.vue index 60273eea5..f60b3b97e 100644 --- a/dashboard/src/views/ProfileView.vue +++ b/dashboard/src/views/ProfileView.vue @@ -270,7 +270,7 @@ onMounted(async () => {
- +
diff --git a/migrations/20250608103424-users-make-avatar-nullable.js b/migrations/20250608103424-users-make-avatar-nullable.js new file mode 100644 index 000000000..317a143ad --- /dev/null +++ b/migrations/20250608103424-users-make-avatar-nullable.js @@ -0,0 +1,19 @@ +'use strict'; + +exports.up = async function (db) { + const AVATAR_NONE = Buffer.from('', 'utf8'); + const AVATAR_GRAVATAR = Buffer.from('gravatar', 'utf8'); + + await db.runSql('ALTER TABLE users MODIFY avatar MEDIUMBLOB'); // remove NOT NULL + const allUsers = await db.runSql('SELECT id, avatar FROM users', []); + + for (const user of allUsers) { + if (AVATAR_GRAVATAR.equals(user.avatar) || AVATAR_NONE.equals(user.avatar)) { + await db.runSql('UPDATE users SET avatar=? WHERE id=?', [ null, user.id ]); // drops avatar support. empty avatar is now null + } + } +}; + +exports.down = async function (db) { + await db.runSql('ALTER TABLE users MODIFY avatar MEDIUMBLOB NOT NULL'); +}; diff --git a/src/branding.js b/src/branding.js index 3e8ffd943..6540ff75a 100644 --- a/src/branding.js +++ b/src/branding.js @@ -53,7 +53,6 @@ async function getCloudronAvatar() { let avatar = await settings.getBlob(settings.CLOUDRON_AVATAR_KEY); if (avatar) return avatar; - // try default fallback avatar = safe.fs.readFileSync(paths.CLOUDRON_DEFAULT_AVATAR_FILE); if (avatar) return avatar; diff --git a/src/constants.js b/src/constants.js index b1051e988..9c9cb8912 100644 --- a/src/constants.js +++ b/src/constants.js @@ -78,11 +78,6 @@ exports = module.exports = { AUTOUPDATE_PATTERN_NEVER: 'never', - // the db field is a blob so we make this explicit - AVATAR_NONE: Buffer.from('', 'utf8'), - AVATAR_GRAVATAR: Buffer.from('gravatar', 'utf8'), - AVATAR_CUSTOM: Buffer.from('custom', 'utf8'), // this is not used here just for reference. The field will contain a byte buffer instead of the type string - SECRET_PLACEHOLDER: String.fromCharCode(0x25CF).repeat(8), // also used in dashboard client.js CLOUDRON: CLOUDRON, diff --git a/src/paths.js b/src/paths.js index b9ff4f570..aab42dc91 100644 --- a/src/paths.js +++ b/src/paths.js @@ -10,15 +10,18 @@ function baseDir() { // cannot reach } +function dashboardDir() { + return constants.TEST ? path.resolve(__dirname, '../dashboard/dist') : path.join(baseDir(), 'box/dashboard/dist'); +} + // keep these values in sync with start.sh exports = module.exports = { - baseDir: baseDir, + baseDir, CLOUDRON_DEFAULT_AVATAR_FILE: path.join(__dirname, 'avatar.png'), INFRA_VERSION_FILE: path.join(baseDir(), 'platformdata/INFRA_VERSION'), CRON_SEED_FILE: path.join(baseDir(), 'platformdata/CRON_SEED'), - DASHBOARD_DIR: constants.TEST ? path.join(__dirname, '../dashboard/dist') : path.join(baseDir(), 'box/dashboard/dist'), - TRANSLATIONS_DIR: constants.TEST ? path.join(__dirname, '../dashboard/dist/translation') : path.join(baseDir(), 'box/dashboard/dist/translation'), + TRANSLATIONS_DIR: path.join(dashboardDir(), 'translation'), PROVIDER_FILE: '/etc/cloudron/PROVIDER', diff --git a/src/routes/apps.js b/src/routes/apps.js index a83254b53..99ac1d999 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -950,6 +950,7 @@ async function downloadBackup(req, res, next) { async function uploadFile(req, res, next) { assert.strictEqual(typeof req.app, 'object'); + assert.strictEqual(typeof req.files, 'object'); if (typeof req.query.file !== 'string' || !req.query.file) return next(new HttpError(400, 'file query argument must be provided')); if (!req.files.file) return next(new HttpError(400, 'file must be provided as multipart')); diff --git a/src/routes/profile.js b/src/routes/profile.js index 52be2b716..d23e8cdc4 100644 --- a/src/routes/profile.js +++ b/src/routes/profile.js @@ -6,7 +6,7 @@ exports = module.exports = { setDisplayName, setEmail, setFallbackEmail, - getAvatar, + getAvatarById, setAvatar, setLanguage, getBackgroundImage, @@ -22,14 +22,8 @@ exports = module.exports = { const assert = require('assert'), AuditSource = require('../auditsource.js'), BoxError = require('../boxerror.js'), - constants = require('../constants.js'), - crypto = require('crypto'), - dashboard = require('../dashboard.js'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, - https = require('https'), - path = require('path'), - paths = require('../paths.js'), safe = require('safetydance'), userDirectory = require('../user-directory.js'), users = require('../users.js'); @@ -48,19 +42,9 @@ async function canEditProfile(req, res, next) { async function get(req, res, next) { assert.strictEqual(typeof req.user, 'object'); - const [error, backgroundImage] = await safe(users.getBackgroundImage(req.user.id)); + const [error, backgroundImage] = await safe(users.getBackgroundImage(req.user)); if (error) return next(BoxError.toHttpError(error)); - const [avatarError, avatar] = await safe(users.getAvatar(req.user.id)); - if (avatarError) return next(BoxError.toHttpError(error)); - - let avatarType; - if (avatar.equals(constants.AVATAR_GRAVATAR)) avatarType = constants.AVATAR_GRAVATAR; - else if (avatar.equals(constants.AVATAR_NONE)) avatarType = constants.AVATAR_NONE; - else avatarType = constants.AVATAR_CUSTOM; - - const { fqdn:dashboardFqdn } = await dashboard.getLocation(); - next(new HttpSuccess(200, { id: req.user.id, username: req.user.username, @@ -73,8 +57,6 @@ async function get(req, res, next) { hasBackgroundImage: !!backgroundImage, language: req.user.language, notificationConfig: req.user.notificationConfig, - avatarUrl: `https://${dashboardFqdn}/api/v1/profile/avatar/${req.user.id}`, - avatarType: avatarType.toString() // this is a Buffer })); } @@ -128,57 +110,30 @@ async function setLanguage(req, res, next) { async function setAvatar(req, res, next) { assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.files, 'object'); - let avatar = typeof req.body.avatar === 'string' ? Buffer.from(req.body.avatar, 'utf8') : null; + const avatar = safe.fs.readFileSync(req.files.avatar.path); + safe.fs.unlinkSync(req.files.avatar.path); + if (!avatar) return next(BoxError.toHttpError(new BoxError(BoxError.FS_ERROR, safe.error.message))); - if (req.files && req.files.avatar) { - avatar = safe.fs.readFileSync(req.files.avatar.path); - safe.fs.unlinkSync(req.files.avatar.path); - if (!avatar) return next(BoxError.toHttpError(new BoxError(BoxError.FS_ERROR, safe.error.message))); - } else if (!avatar || (!avatar.equals(constants.AVATAR_GRAVATAR) && !avatar.equals(constants.AVATAR_NONE))) { - return next(new HttpError(400, `avatar must be a file, ${constants.AVATAR_GRAVATAR} or ${constants.AVATAR_NONE}`)); - } - - const [error] = await safe(users.setAvatar(req.user.id, avatar)); + const [error] = await safe(users.setAvatar(req.user, avatar)); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(202, {})); } -async function getAvatar(req, res, next) { - assert.strictEqual(typeof req.params.identifier, 'string'); +async function getAvatarById(req, res, next) { + assert.strictEqual(typeof req.params, 'object'); - const [,userId, ext] = req.params.identifier.match(/^(.*?)(?:\.(\w+))?$/); - const [userError, user] = await safe(users.get(userId)); + const [userError, user] = await safe(users.get(req.params.identifier)); if (userError) return next(BoxError.toHttpError(userError)); - const [avatarError, avatar] = await safe(users.getAvatar(userId)); + const [avatarError, avatar] = await safe(users.getAvatar(user)); if (avatarError) return next(BoxError.toHttpError(avatarError)); + if (!avatar) return next(new HttpError(404, 'no avatar')); - if (avatar.equals(constants.AVATAR_GRAVATAR)) { - const gravatarHash = crypto.createHash('md5').update(user.email).digest('hex'); - https.get(`https://www.gravatar.com/avatar/${gravatarHash}.jpg`, function (upstreamRes) { - if (upstreamRes.status !== 200) { - console.error('Gravatar error:', upstreamRes.status); - return res.status(404).end(); - } - - res.set('content-type', 'image/jpeg'); - upstreamRes.pipe(res); - }).on('error', (e) => { - console.error('Gravatar error:', e.message); - return res.status(404).end(); - }); - } else if (avatar.equals(constants.AVATAR_NONE)) { - if (ext) { - res.sendFile(path.join(paths.DASHBOARD_DIR, '/img/avatar-default-symbolic.png')); - } else { - res.sendFile(path.join(paths.DASHBOARD_DIR, '/img/avatar-default-symbolic.svg')); - } - } else { - res.set('Content-Type', 'image/png'); - res.send(avatar); - } + res.set('Content-Type', 'image/png'); + res.status(200).send(avatar); } async function setBackgroundImage(req, res, next) { @@ -190,7 +145,7 @@ async function setBackgroundImage(req, res, next) { safe.fs.unlinkSync(req.files.backgroundImage.path); if (!backgroundImage) return next(BoxError.toHttpError(new BoxError(BoxError.FS_ERROR, safe.error.message))); - const [error] = await safe(users.setBackgroundImage(req.user.id, backgroundImage)); + const [error] = await safe(users.setBackgroundImage(req.user, backgroundImage)); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, {})); @@ -199,7 +154,7 @@ async function setBackgroundImage(req, res, next) { async function unsetBackgroundImage(req, res, next) { assert.strictEqual(typeof req.user, 'object'); - const [error] = await safe(users.setBackgroundImage(req.user.id, null)); + const [error] = await safe(users.setBackgroundImage(req.user, null)); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, {})); @@ -208,7 +163,7 @@ async function unsetBackgroundImage(req, res, next) { async function getBackgroundImage(req, res, next) { assert.strictEqual(typeof req.user, 'object'); - const [error, backgroundImage] = await safe(users.getBackgroundImage(req.user.id)); + const [error, backgroundImage] = await safe(users.getBackgroundImage(req.user)); if (error) return next(BoxError.toHttpError(error)); if (!backgroundImage) return next(new HttpError(404, 'no background set')); diff --git a/src/routes/test/profile-test.js b/src/routes/test/profile-test.js index 5a18d7392..d7de1ea5a 100644 --- a/src/routes/test/profile-test.js +++ b/src/routes/test/profile-test.js @@ -10,9 +10,6 @@ const common = require('./common.js'), expect = require('expect.js'), speakeasy = require('speakeasy'), superagent = require('../../superagent.js'), - fs = require('fs'), - path = require('path'), - paths = require('../../paths.js'), tokens = require('../../tokens.js'); describe('Profile API', function () { @@ -309,13 +306,9 @@ describe('Profile API', function () { describe('avatar', function () { let customAvatarSize = 0; - it('placeholder by default', async function () { - const defaultAvatar = fs.readFileSync(path.join(paths.DASHBOARD_DIR, '/img/avatar-default-symbolic.svg')); - - const response = await superagent.get(`${serverUrl}/api/v1/profile/avatar/${user.id}`); - - expect(response.headers['content-type']).to.equal('image/svg+xml'); - expect(response.body).to.eql(defaultAvatar); + it('empty by default', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/profile/avatar/${user.id}`).ok(() => true); + expect(response.status).to.be(404); }); it('can set custom avatar', async function () { @@ -329,47 +322,11 @@ describe('Profile API', function () { }); it('did set custom avatar', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/profile`) - .query({ access_token: user.token }); - expect(response.body.avatarUrl).to.contain('/api/v1/profile/avatar/'); - - const response2 = await superagent.get(`${serverUrl}/api/v1/profile/avatar/${user.id}`) + const response = await superagent.get(`${serverUrl}/api/v1/profile/avatar/${user.id}`) .ok(() => true); - expect(parseInt(response2.headers['content-length'])).to.equal(customAvatarSize); - expect(response2.status).to.equal(200); - }); - - it('can set gravatar', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/profile/avatar`) - .query({ access_token: user.token }) - .send({ avatar: 'gravatar' }); - - expect(response.status).to.be(202); - }); - - it('did set gravatar', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/profile`) - .query({ access_token: user.token }); - - expect(response.body.avatarType).to.contain('gravatar'); - }); - - it('can unset avatar', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/profile/avatar`) - .query({ access_token: user.token }) - .send({ avatar: '' }); - - expect(response.status).to.be(202); - }); - - it('did unset avatar', async function () { - const defaultAvatar = fs.readFileSync(path.join(paths.DASHBOARD_DIR, '/img/avatar-default-symbolic.svg')); - - const response = await superagent.get(`${serverUrl}/api/v1/profile/avatar/${user.id}`); - - expect(response.headers['content-type']).to.equal('image/svg+xml'); - expect(response.body).to.eql(defaultAvatar); + expect(parseInt(response.headers['content-length'])).to.equal(customAvatarSize); + expect(response.status).to.equal(200); }); }); diff --git a/src/server.js b/src/server.js index 906999772..9b2892ae4 100644 --- a/src/server.js +++ b/src/server.js @@ -172,7 +172,7 @@ async function initializeExpressSync() { router.post('/api/v1/profile/email', json, token, authorizeUser, routes.profile.canEditProfile, routes.users.verifyPassword, routes.profile.setEmail); router.post('/api/v1/profile/fallback_email', json, token, authorizeUser, routes.profile.canEditProfile, routes.users.verifyPassword, routes.profile.setFallbackEmail); router.post('/api/v1/profile/language', json, token, authorizeUser, routes.profile.setLanguage); - router.get ('/api/v1/profile/avatar/:identifier', routes.profile.getAvatar); // this is not scoped so it can used directly in img tag + router.get ('/api/v1/profile/avatar/:identifier', routes.profile.getAvatarById); // this is not scoped so it can used directly in img tag router.post('/api/v1/profile/avatar', token, authorizeUser, multipart, routes.profile.setAvatar); // avatar is not exposed in LDAP. so it's personal and not locked router.get ('/api/v1/profile/background_image', token, authorizeUser, routes.profile.getBackgroundImage); router.post('/api/v1/profile/background_image', token, authorizeUser, multipart, routes.profile.setBackgroundImage); // backgroundImage is not exposed in LDAP. so it's personal and not locked diff --git a/src/test/common.js b/src/test/common.js index a941c0518..9253515e9 100644 --- a/src/test/common.js +++ b/src/test/common.js @@ -102,7 +102,7 @@ const admin = { groupIds: [], role: 'owner', source: '', - avatar: constants.AVATAR_GRAVATAR, + avatar: null, active: true, }; @@ -119,7 +119,7 @@ const user = { groupIds: [], displayName: 'Normal User', source: '', - avatar: constants.AVATAR_NONE, + avatar: null, active: true, }; diff --git a/src/users.js b/src/users.js index d338d3818..6c9936823 100644 --- a/src/users.js +++ b/src/users.js @@ -91,6 +91,7 @@ const appPasswords = require('./apppasswords.js'), mailer = require('./mailer.js'), mysql = require('mysql'), notifications = require('./notifications'), + paths = require('./paths.js'), qrcode = require('qrcode'), safe = require('safetydance'), settings = require('./settings.js'), @@ -267,7 +268,7 @@ async function add(email, data, auditSource) { displayName, source, role, - avatar: constants.AVATAR_NONE, + avatar: null, language: '', notificationConfigJson: notificationConfig ? JSON.stringify(notificationConfig) : null }; @@ -948,35 +949,35 @@ function compareRoles(role1, role2) { return roleInt1 - roleInt2; } -async function getAvatar(id) { - assert.strictEqual(typeof id, 'string'); +async function getAvatar(user) { + assert.strictEqual(typeof user, 'object'); - const result = await database.query('SELECT avatar FROM users WHERE id = ?', [ id ]); + const result = await database.query('SELECT avatar FROM users WHERE id = ?', [ user.id ]); if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found'); return result[0].avatar; } -async function setAvatar(id, avatar) { - assert.strictEqual(typeof id, 'string'); +async function setAvatar(user, avatar) { + assert.strictEqual(typeof user, 'object'); assert(Buffer.isBuffer(avatar)); - const result = await database.query('UPDATE users SET avatar=? WHERE id = ?', [ avatar, id ]); + const result = await database.query('UPDATE users SET avatar=? WHERE id = ?', [ avatar, user.id ]); if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found'); } -async function getBackgroundImage(id) { - assert.strictEqual(typeof id, 'string'); +async function getBackgroundImage(user) { + assert.strictEqual(typeof user, 'object'); - const result = await database.query('SELECT backgroundImage FROM users WHERE id = ?', [ id ]); + const result = await database.query('SELECT backgroundImage FROM users WHERE id = ?', [ user.id ]); if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found'); return result[0].backgroundImage; } -async function setBackgroundImage(id, backgroundImage) { - assert.strictEqual(typeof id, 'string'); +async function setBackgroundImage(user, backgroundImage) { + assert.strictEqual(typeof user, 'object'); assert(Buffer.isBuffer(backgroundImage) || backgroundImage === null); - const result = await database.query('UPDATE users SET backgroundImage=? WHERE id = ?', [ backgroundImage, id ]); + const result = await database.query('UPDATE users SET backgroundImage=? WHERE id = ?', [ backgroundImage, user.id ]); if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found'); }