profile: drop gravatar support

gravatar is owned by an external entity (Automattic) and we have an
unnecessary dep to this service. users can just upload a profile pic
This commit is contained in:
Girish Ramakrishnan
2025-06-08 12:42:13 +02:00
parent cd45046724
commit a93c85ebc9
13 changed files with 69 additions and 137 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -270,7 +270,7 @@ onMounted(async () => {
<div style="display: flex; flex-wrap: wrap; gap: 20px">
<div style="width: 128px;">
<ImagePicker :src="user.avatarUrl" fallback-src="/img/background-image-placeholder.svg" :size="512" :save-handler="onAvatarSubmit" display-width="128px"/>
<ImagePicker :src="user.avatarUrl" fallback-src="/img/avatar-default-symbolic.svg" :size="512" :save-handler="onAvatarSubmit" display-width="128px"/>
</div>
<div style="flex-grow: 1;">
<SettingsItem>

View File

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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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',

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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,
};

View File

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