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:
1
CHANGES
1
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
migrations/20250608103424-users-make-avatar-nullable.js
Normal file
19
migrations/20250608103424-users-make-avatar-nullable.js
Normal 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');
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
27
src/users.js
27
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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user