|
| 1 | +import { PassThrough } from 'stream'; |
| 2 | +import { URL } from 'url'; |
| 3 | +import pump from 'pump'; |
| 4 | +import isStream from 'is-stream'; |
| 5 | +import imageType from 'image-type'; |
| 6 | +import props from 'p-props'; |
| 7 | +import DefaultStore from 'fs-blob-store'; |
| 8 | +import PermissionError from '../errors/PermissionError'; |
| 9 | + |
| 10 | +function toImageStream(input) { |
| 11 | + const output = new PassThrough(); |
| 12 | + input.pipe(output); |
| 13 | + |
| 14 | + return new Promise((resolve, reject) => { |
| 15 | + input.once('data', (chunk) => { |
| 16 | + const type = imageType(chunk); |
| 17 | + if (!type) { |
| 18 | + input.destroy(); |
| 19 | + output.destroy(); |
| 20 | + reject(new Error('toImageStream: Not an image.')); |
| 21 | + } |
| 22 | + if (type.mime !== 'image/png' && type.mime !== 'image/jpeg') { |
| 23 | + input.destroy(); |
| 24 | + output.destroy(); |
| 25 | + reject(new Error('toImageStream: Only PNG and JPEG are allowed.')); |
| 26 | + } |
| 27 | + |
| 28 | + Object.assign(output, type); |
| 29 | + resolve(output); |
| 30 | + }); |
| 31 | + }); |
| 32 | +} |
| 33 | + |
| 34 | +async function assertPermission(user, permission) { |
| 35 | + const allowed = await user.can(permission); |
| 36 | + if (!allowed) { |
| 37 | + throw new PermissionError(`User does not have the "${permission}" role.`); |
| 38 | + } |
| 39 | + return true; |
| 40 | +} |
| 41 | + |
| 42 | +const defaultOptions = { |
| 43 | + sigil: true, |
| 44 | + store: null, |
| 45 | +}; |
| 46 | + |
| 47 | +class Avatars { |
| 48 | + constructor(uw, options) { |
| 49 | + this.uw = uw; |
| 50 | + this.options = { ...defaultOptions, ...options }; |
| 51 | + |
| 52 | + this.store = this.options.store; |
| 53 | + if (typeof this.store === 'string') { |
| 54 | + this.store = new DefaultStore({ |
| 55 | + path: this.store, |
| 56 | + }); |
| 57 | + } |
| 58 | + |
| 59 | + this.magicAvatars = new Map(); |
| 60 | + |
| 61 | + if (this.options.sigil) { |
| 62 | + this.addMagicAvatar( |
| 63 | + 'sigil', |
| 64 | + user => `https://sigil.u-wave.net/${user.id}`, |
| 65 | + ); |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + /** |
| 70 | + * Define an avatar type, that can generate avatar URLs for |
| 71 | + * any user. eg. gravatar or an identicon service |
| 72 | + */ |
| 73 | + addMagicAvatar(name, generator) { |
| 74 | + if (this.magicAvatars.has(name)) { |
| 75 | + throw new Error(`Magic avatar "${name}" already exists.`); |
| 76 | + } |
| 77 | + if (typeof name !== 'string') { |
| 78 | + throw new Error('Magic avatar name must be a string.'); |
| 79 | + } |
| 80 | + if (typeof generator !== 'function') { |
| 81 | + throw new Error('Magic avatar generator must be a function.'); |
| 82 | + } |
| 83 | + |
| 84 | + this.magicAvatars.set(name, generator); |
| 85 | + } |
| 86 | + |
| 87 | + /** |
| 88 | + * Get the available magic avatars for a user. |
| 89 | + */ |
| 90 | + async getMagicAvatars(userID) { |
| 91 | + const { users } = this.uw; |
| 92 | + const user = await users.getUser(userID); |
| 93 | + |
| 94 | + const promises = new Map(); |
| 95 | + this.magicAvatars.forEach((generator, name) => { |
| 96 | + promises.set(name, generator(user)); |
| 97 | + }); |
| 98 | + |
| 99 | + const avatars = await props(promises); |
| 100 | + |
| 101 | + return Array.from(avatars).map(([name, url]) => ({ |
| 102 | + type: 'magic', |
| 103 | + name, |
| 104 | + url, |
| 105 | + })); |
| 106 | + } |
| 107 | + |
| 108 | + async setMagicAvatar(userID, name) { |
| 109 | + const { users } = this.uw; |
| 110 | + |
| 111 | + if (!this.magicAvatars.has(name)) { |
| 112 | + throw new Error(`Magic avatar ${name} does not exist.`); |
| 113 | + } |
| 114 | + |
| 115 | + const user = await users.getUser(userID); |
| 116 | + const generator = this.magicAvatars.get(name); |
| 117 | + |
| 118 | + const url = await generator(user); |
| 119 | + |
| 120 | + await user.update({ avatar: url }); |
| 121 | + } |
| 122 | + |
| 123 | + /** |
| 124 | + * Get the available social avatars for a user. |
| 125 | + */ |
| 126 | + async getSocialAvatars(userID) { |
| 127 | + const { users } = this.uw; |
| 128 | + const { Authentication } = this.uw.models; |
| 129 | + const user = await users.getUser(userID); |
| 130 | + |
| 131 | + const socialAvatars = await Authentication |
| 132 | + .find({ |
| 133 | + $comment: 'Find social avatars for a user.', |
| 134 | + user, |
| 135 | + type: { $ne: 'local' }, |
| 136 | + avatar: { $exists: true, $ne: null }, |
| 137 | + }) |
| 138 | + .select({ type: true, avatar: true }) |
| 139 | + .lean(); |
| 140 | + |
| 141 | + return socialAvatars.map(({ type, avatar }) => ({ |
| 142 | + type: 'social', |
| 143 | + service: type, |
| 144 | + url: avatar, |
| 145 | + })); |
| 146 | + } |
| 147 | + |
| 148 | + /** |
| 149 | + * Use the avatar from the given third party service. |
| 150 | + */ |
| 151 | + async setSocialAvatar(userID, service) { |
| 152 | + const { users } = this.uw; |
| 153 | + const { Authentication } = this.uw.models; |
| 154 | + const user = await users.getUser(userID); |
| 155 | + |
| 156 | + const auth = await Authentication.findOne({ user, type: service }); |
| 157 | + if (!auth || !auth.avatar) { |
| 158 | + throw new Error(`No avatar available for ${service}.`); |
| 159 | + } |
| 160 | + try { |
| 161 | + new URL(auth.avatar); // eslint-disable-line no-new |
| 162 | + } catch { |
| 163 | + throw new Error(`Invalid avatar URL for ${service}.`); |
| 164 | + } |
| 165 | + |
| 166 | + await user.setAvatar(auth.avatar); |
| 167 | + } |
| 168 | + |
| 169 | + /** |
| 170 | + * Check if custom avatar support is enabled. |
| 171 | + */ |
| 172 | + supportsCustomAvatars() { |
| 173 | + return typeof this.options.publicPath === 'string' |
| 174 | + && typeof this.store === 'object'; |
| 175 | + } |
| 176 | + |
| 177 | + /** |
| 178 | + * Use a custom avatar. |
| 179 | + */ |
| 180 | + async setCustomAvatar(userID, stream) { |
| 181 | + const { users } = this.uw; |
| 182 | + |
| 183 | + if (!this.supportsCustomAvatars()) { |
| 184 | + throw new PermissionError('Custom avatars are not enabled.'); |
| 185 | + } |
| 186 | + |
| 187 | + const user = await users.getUser(userID); |
| 188 | + await assertPermission(user, 'avatar.custom'); |
| 189 | + |
| 190 | + if (!isStream(stream)) { |
| 191 | + throw new TypeError('Custom avatar must be a stream (eg. a http Request instance).'); |
| 192 | + } |
| 193 | + |
| 194 | + const imageStream = await toImageStream(stream); |
| 195 | + const metadata = await new Promise((resolve, reject) => { |
| 196 | + const writeStream = this.store.createWriteStream({ |
| 197 | + key: `${user.id}.${imageStream.type}`, |
| 198 | + }, (err, meta) => { |
| 199 | + if (err) reject(err); |
| 200 | + else resolve(meta); |
| 201 | + }); |
| 202 | + pump(imageStream, writeStream); |
| 203 | + }); |
| 204 | + |
| 205 | + const finalKey = metadata.key; |
| 206 | + const url = new URL(finalKey, this.options.publicPath); |
| 207 | + |
| 208 | + await user.setAvatar(url); |
| 209 | + } |
| 210 | + |
| 211 | + async getAvailableAvatars(userID) { |
| 212 | + const { users } = this.uw; |
| 213 | + const user = await users.getUser(userID); |
| 214 | + |
| 215 | + const all = await Promise.all([ |
| 216 | + this.getMagicAvatars(user), |
| 217 | + this.getSocialAvatars(user), |
| 218 | + ]); |
| 219 | + |
| 220 | + // flatten |
| 221 | + return [].concat(...all); |
| 222 | + } |
| 223 | + |
| 224 | + async setAvatar(userID, avatar) { |
| 225 | + if (avatar.type === 'magic') { |
| 226 | + return this.setMagicAvatar(userID, avatar.name); |
| 227 | + } |
| 228 | + if (avatar.type === 'social') { |
| 229 | + return this.setSocialAvatar(userID, avatar.service); |
| 230 | + } |
| 231 | + throw new Error(`Unknown avatar type "${avatar.type}"`); |
| 232 | + } |
| 233 | +} |
| 234 | + |
| 235 | +export default function avatarsPlugin(options = {}) { |
| 236 | + return (uw) => { |
| 237 | + uw.avatars = new Avatars(uw, options); // eslint-disable-line no-param-reassign |
| 238 | + }; |
| 239 | +} |
0 commit comments