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');
}