diff --git a/.babelrc.js b/.babelrc.js deleted file mode 100644 index 73c3f7df..00000000 --- a/.babelrc.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = (api) => { - // TODO configure this maybe - api.cache.never(); - - return { - plugins: [ - process.env.BABEL_ENV !== 'rollup' && '@babel/plugin-transform-modules-commonjs', - '@babel/plugin-proposal-class-properties', - '@babel/plugin-proposal-private-methods', - '@babel/plugin-transform-flow-comments', - ].filter(Boolean), - }; -}; diff --git a/package.json b/package.json index 8fb79f48..c6a2b339 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/Uwave.js b/src/Uwave.js index 5bd4773d..c30a7189 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -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'); @@ -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()); diff --git a/src/models/User.js b/src/models/User.js index 0e14f8bd..81528254 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -104,6 +104,14 @@ function userModel() { return uw.users.updatePassword(this, password); } + /** + * @param {string} avatar + * @return {Promise} + */ + setAvatar(avatar) { + return uw.users.updateUser(this, { avatar }); + } + /** * @return {Promise} */ diff --git a/src/plugins/avatars.js b/src/plugins/avatars.js new file mode 100644 index 00000000..cc248976 --- /dev/null +++ b/src/plugins/avatars.js @@ -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 + }; +}