From 760508878a00ba91735485be3726b80dec098761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 19 Aug 2018 14:12:05 +0200 Subject: [PATCH 1/5] Add avatar plugin. --- .babelrc.js | 1 + package.json | 7 +- src/Uwave.js | 2 + src/models/User.js | 4 + src/plugins/avatars.js | 239 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/plugins/avatars.js diff --git a/.babelrc.js b/.babelrc.js index f65ad765..6843e70f 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -5,6 +5,7 @@ module.exports = (api) => { return { plugins: [ process.env.BABEL_ENV !== 'rollup' && '@babel/plugin-transform-modules-commonjs', + '@babel/plugin-proposal-optional-catch-binding', '@babel/plugin-syntax-object-rest-spread', '@babel/plugin-proposal-class-properties', '@babel/plugin-transform-flow-comments', diff --git a/package.json b/package.json index 25aa7888..733b1454 100644 --- a/package.json +++ b/package.json @@ -19,18 +19,23 @@ "bcryptjs": "^2.4.3", "debug": "^4.0.0", "escape-string-regexp": "^1.0.5", + "fs-blob-store": "^5.2.1", + "image-type": "^3.0.0", "ioredis": "^4.0.0", + "is-stream": "^1.1.0", "lodash": "^4.16.3", "mongoose": "^5.3.4", "ms": "^2.1.1", "p-each-series": "^1.0.0", - "p-props": "^1.1.0", + "p-props": "^1.2.0", + "pump": "^3.0.0", "redlock": "^3.1.0", "transliteration": "^1.6.2" }, "devDependencies": { "@babel/core": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", "@babel/plugin-syntax-object-rest-spread": "^7.0.0", "@babel/plugin-transform-flow-comments": "^7.0.0", "@babel/plugin-transform-modules-commonjs": "^7.0.0", diff --git a/src/Uwave.js b/src/Uwave.js index 758d9d57..c3f71585 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -13,6 +13,7 @@ import chat from './plugins/chat'; import motd from './plugins/motd'; import playlists from './plugins/playlists'; import users from './plugins/users'; +import avatars from './plugins/avatars'; import bans from './plugins/bans'; import history from './plugins/history'; import acl from './plugins/acl'; @@ -63,6 +64,7 @@ export default 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 f983bbda..f05d3728 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -80,6 +80,10 @@ export default function userModel() { return uw.users.updatePassword(this, password); } + setAvatar(avatar: string): Promise { + return uw.users.updateUser(this, { avatar }); + } + getPlaylists(): Promise { return uw.playlists.getUserPlaylists(this); } diff --git a/src/plugins/avatars.js b/src/plugins/avatars.js new file mode 100644 index 00000000..db5a7200 --- /dev/null +++ b/src/plugins/avatars.js @@ -0,0 +1,239 @@ +import { PassThrough } from 'stream'; +import { URL } from 'url'; +import pump from 'pump'; +import isStream from 'is-stream'; +import imageType from 'image-type'; +import props from 'p-props'; +import DefaultStore from 'fs-blob-store'; +import PermissionError from '../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, + }); + } + + 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, + })); + } + + 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. + */ + 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}"`); + } +} + +export default function avatarsPlugin(options = {}) { + return (uw) => { + uw.avatars = new Avatars(uw, options); // eslint-disable-line no-param-reassign + }; +} From 674ed6772b0d39d67b70b34bfe6514762f0491a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 9 Oct 2018 12:37:10 +0200 Subject: [PATCH 2/5] Check arguments; skip magic avatars with `null` url. --- src/plugins/avatars.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/plugins/avatars.js b/src/plugins/avatars.js index db5a7200..f3d351d3 100644 --- a/src/plugins/avatars.js +++ b/src/plugins/avatars.js @@ -56,6 +56,10 @@ class Avatars { }); } + if (typeof this.store === 'object' && typeof this.opts.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) { @@ -102,7 +106,7 @@ class Avatars { type: 'magic', name, url, - })); + })).filter(({ url }) => url != null); } async setMagicAvatar(userID, name) { @@ -175,7 +179,7 @@ class Avatars { } /** - * Use a custom avatar. + * Use a custom avatar, read from a stream. */ async setCustomAvatar(userID, stream) { const { users } = this.uw; From a57804b30bb008b1a0725a64e6834e057d3d83a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 9 Oct 2018 14:45:31 +0200 Subject: [PATCH 3/5] undefined --- src/plugins/avatars.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/avatars.js b/src/plugins/avatars.js index f3d351d3..bbf6b422 100644 --- a/src/plugins/avatars.js +++ b/src/plugins/avatars.js @@ -56,7 +56,8 @@ class Avatars { }); } - if (typeof this.store === 'object' && typeof this.opts.publicPath !== 'string') { + 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.'); } From e55101b82b1c9f4ea0ebc6d902d2720b230b8766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Thu, 18 Oct 2018 11:55:44 +0200 Subject: [PATCH 4/5] Lint --- src/plugins/avatars.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/avatars.js b/src/plugins/avatars.js index bbf6b422..d37fc1e1 100644 --- a/src/plugins/avatars.js +++ b/src/plugins/avatars.js @@ -56,8 +56,8 @@ class Avatars { }); } - if (typeof this.store === 'object' && this.store != null && - typeof this.options.publicPath !== 'string') { + 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.'); } From f713e917ca212ccc4f8334e2c6a7261718a7df01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 21 Feb 2020 14:09:38 +0100 Subject: [PATCH 5/5] Update avatars plugin for mainline changes --- package.json | 5 +++-- src/plugins/avatars.js | 18 +++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index ea859396..ece49397 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "escape-string-regexp": "^2.0.0", "fs-blob-store": "^5.2.1", "http-errors": "^1.7.3", - "image-type": "^3.0.0", - "is-stream": "^1.1.0", "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", @@ -39,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/plugins/avatars.js b/src/plugins/avatars.js index d37fc1e1..cc248976 100644 --- a/src/plugins/avatars.js +++ b/src/plugins/avatars.js @@ -1,11 +1,11 @@ -import { PassThrough } from 'stream'; -import { URL } from 'url'; -import pump from 'pump'; -import isStream from 'is-stream'; -import imageType from 'image-type'; -import props from 'p-props'; -import DefaultStore from 'fs-blob-store'; -import PermissionError from '../errors/PermissionError'; +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(); @@ -237,7 +237,7 @@ class Avatars { } } -export default function avatarsPlugin(options = {}) { +module.exports = function avatarsPlugin(options = {}) { return (uw) => { uw.avatars = new Avatars(uw, options); // eslint-disable-line no-param-reassign };