Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add avatar plugin. #287

Draft
wants to merge 7 commits into
base: default
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .babelrc.js

This file was deleted.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
"cookie-parser": "^1.4.4",
"debug": "^4.1.1",
"escape-string-regexp": "^4.0.0",
"fs-blob-store": "^5.2.1",
"htmlescape": "^1.1.1",
"http-errors": "^1.7.3",
"i18next": "^19.0.3",
"image-type": "^4.1.0",
"ioredis": "^4.14.1",
"is-stream": "^2.0.0",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.15",
"mongoose": "^5.8.9",
Expand All @@ -36,6 +39,7 @@
"passport": "^0.4.1",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
"pump": "^3.0.0",
"qs": "^6.9.1",
"random-string": "^0.2.0",
"ratelimiter": "^3.4.0",
Expand Down
2 changes: 2 additions & 0 deletions src/Uwave.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const chat = require('./plugins/chat');
const motd = require('./plugins/motd');
const playlists = require('./plugins/playlists');
const users = require('./plugins/users');
const avatars = require('./plugins/avatars');
const bans = require('./plugins/bans');
const history = require('./plugins/history');
const acl = require('./plugins/acl');
Expand Down Expand Up @@ -83,6 +84,7 @@ class UwaveServer extends EventEmitter {
this.use(motd());
this.use(playlists());
this.use(users());
this.use(avatars());
this.use(bans());
this.use(history());
this.use(acl());
Expand Down
8 changes: 8 additions & 0 deletions src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ function userModel() {
return uw.users.updatePassword(this, password);
}

/**
* @param {string} avatar
* @return {Promise<unknown>}
*/
setAvatar(avatar) {
return uw.users.updateUser(this, { avatar });
}

/**
* @return {Promise<unknown[]>}
*/
Expand Down
244 changes: 244 additions & 0 deletions src/plugins/avatars.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
const { PassThrough } = require('stream');
const { URL } = require('url');
const pump = require('pump');
const isStream = require('is-stream');
const imageType = require('image-type');
const props = require('p-props');
const DefaultStore = require('fs-blob-store');
const PermissionError = require('../errors/PermissionError');

function toImageStream(input) {
const output = new PassThrough();
input.pipe(output);

return new Promise((resolve, reject) => {
input.once('data', (chunk) => {
const type = imageType(chunk);
if (!type) {
input.destroy();
output.destroy();
reject(new Error('toImageStream: Not an image.'));
}
if (type.mime !== 'image/png' && type.mime !== 'image/jpeg') {
input.destroy();
output.destroy();
reject(new Error('toImageStream: Only PNG and JPEG are allowed.'));
}

Object.assign(output, type);
resolve(output);
});
});
}

async function assertPermission(user, permission) {
const allowed = await user.can(permission);
if (!allowed) {
throw new PermissionError(`User does not have the "${permission}" role.`);
}
return true;
}

const defaultOptions = {
sigil: true,
store: null,
};

class Avatars {
constructor(uw, options) {
this.uw = uw;
this.options = { ...defaultOptions, ...options };

this.store = this.options.store;
if (typeof this.store === 'string') {
this.store = new DefaultStore({
path: this.store,
});
}

if (typeof this.store === 'object' && this.store != null
&& typeof this.options.publicPath !== 'string') {
throw new TypeError('`publicPath` is not set, but it is required because `store` is set.');
}

this.magicAvatars = new Map();

if (this.options.sigil) {
this.addMagicAvatar(
'sigil',
user => `https://sigil.u-wave.net/${user.id}`,
);
}
}

/**
* Define an avatar type, that can generate avatar URLs for
* any user. eg. gravatar or an identicon service
*/
addMagicAvatar(name, generator) {
if (this.magicAvatars.has(name)) {
throw new Error(`Magic avatar "${name}" already exists.`);
}
if (typeof name !== 'string') {
throw new Error('Magic avatar name must be a string.');
}
if (typeof generator !== 'function') {
throw new Error('Magic avatar generator must be a function.');
}

this.magicAvatars.set(name, generator);
}

/**
* Get the available magic avatars for a user.
*/
async getMagicAvatars(userID) {
const { users } = this.uw;
const user = await users.getUser(userID);

const promises = new Map();
this.magicAvatars.forEach((generator, name) => {
promises.set(name, generator(user));
});

const avatars = await props(promises);

return Array.from(avatars).map(([name, url]) => ({
type: 'magic',
name,
url,
})).filter(({ url }) => url != null);
}

async setMagicAvatar(userID, name) {
const { users } = this.uw;

if (!this.magicAvatars.has(name)) {
throw new Error(`Magic avatar ${name} does not exist.`);
}

const user = await users.getUser(userID);
const generator = this.magicAvatars.get(name);

const url = await generator(user);

await user.update({ avatar: url });
}

/**
* Get the available social avatars for a user.
*/
async getSocialAvatars(userID) {
const { users } = this.uw;
const { Authentication } = this.uw.models;
const user = await users.getUser(userID);

const socialAvatars = await Authentication
.find({
$comment: 'Find social avatars for a user.',
user,
type: { $ne: 'local' },
avatar: { $exists: true, $ne: null },
})
.select({ type: true, avatar: true })
.lean();

return socialAvatars.map(({ type, avatar }) => ({
type: 'social',
service: type,
url: avatar,
}));
}

/**
* Use the avatar from the given third party service.
*/
async setSocialAvatar(userID, service) {
const { users } = this.uw;
const { Authentication } = this.uw.models;
const user = await users.getUser(userID);

const auth = await Authentication.findOne({ user, type: service });
if (!auth || !auth.avatar) {
throw new Error(`No avatar available for ${service}.`);
}
try {
new URL(auth.avatar); // eslint-disable-line no-new
} catch {
throw new Error(`Invalid avatar URL for ${service}.`);
}

await user.setAvatar(auth.avatar);
}

/**
* Check if custom avatar support is enabled.
*/
supportsCustomAvatars() {
return typeof this.options.publicPath === 'string'
&& typeof this.store === 'object';
}

/**
* Use a custom avatar, read from a stream.
*/
async setCustomAvatar(userID, stream) {
const { users } = this.uw;

if (!this.supportsCustomAvatars()) {
throw new PermissionError('Custom avatars are not enabled.');
}

const user = await users.getUser(userID);
await assertPermission(user, 'avatar.custom');

if (!isStream(stream)) {
throw new TypeError('Custom avatar must be a stream (eg. a http Request instance).');
}

const imageStream = await toImageStream(stream);
const metadata = await new Promise((resolve, reject) => {
const writeStream = this.store.createWriteStream({
key: `${user.id}.${imageStream.type}`,
}, (err, meta) => {
if (err) reject(err);
else resolve(meta);
});
pump(imageStream, writeStream);
});

const finalKey = metadata.key;
const url = new URL(finalKey, this.options.publicPath);

await user.setAvatar(url);
}

async getAvailableAvatars(userID) {
const { users } = this.uw;
const user = await users.getUser(userID);

const all = await Promise.all([
this.getMagicAvatars(user),
this.getSocialAvatars(user),
]);

// flatten
return [].concat(...all);
}

async setAvatar(userID, avatar) {
if (avatar.type === 'magic') {
return this.setMagicAvatar(userID, avatar.name);
}
if (avatar.type === 'social') {
return this.setSocialAvatar(userID, avatar.service);
}
throw new Error(`Unknown avatar type "${avatar.type}"`);
}
}

module.exports = function avatarsPlugin(options = {}) {
return (uw) => {
uw.avatars = new Avatars(uw, options); // eslint-disable-line no-param-reassign
};
}