Skip to content

Commit 3ba57e3

Browse files
committed
Add avatar plugin.
1 parent e9186d8 commit 3ba57e3

File tree

5 files changed

+252
-1
lines changed

5 files changed

+252
-1
lines changed

.babelrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module.exports = (api) => {
55
return {
66
plugins: [
77
process.env.BABEL_ENV !== 'rollup' && '@babel/plugin-transform-modules-commonjs',
8+
'@babel/plugin-proposal-optional-catch-binding',
89
'@babel/plugin-syntax-object-rest-spread',
910
'@babel/plugin-proposal-class-properties',
1011
'@babel/plugin-transform-flow-comments',

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,23 @@
1919
"bcryptjs": "^2.4.3",
2020
"debug": "^3.1.0",
2121
"escape-string-regexp": "^1.0.5",
22+
"fs-blob-store": "^5.2.1",
23+
"image-type": "^3.0.0",
2224
"ioredis": "^4.0.0",
25+
"is-stream": "^1.1.0",
2326
"lodash": "^4.16.3",
2427
"mongoose": "^5.2.0",
2528
"ms": "^2.1.1",
2629
"p-each-series": "^1.0.0",
27-
"p-props": "^1.1.0",
30+
"p-props": "^1.2.0",
31+
"pump": "^3.0.0",
2832
"redlock": "^3.1.0",
2933
"transliteration": "^1.6.2"
3034
},
3135
"devDependencies": {
3236
"@babel/core": "7.0.0-rc.1",
3337
"@babel/plugin-proposal-class-properties": "7.0.0-rc.1",
38+
"@babel/plugin-proposal-optional-catch-binding": "^7.0.0-rc.1",
3439
"@babel/plugin-syntax-object-rest-spread": "7.0.0-rc.1",
3540
"@babel/plugin-transform-flow-comments": "7.0.0-rc.1",
3641
"@babel/plugin-transform-modules-commonjs": "7.0.0-rc.1",

src/Uwave.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import chat from './plugins/chat';
1313
import motd from './plugins/motd';
1414
import playlists from './plugins/playlists';
1515
import users from './plugins/users';
16+
import avatars from './plugins/avatars';
1617
import bans from './plugins/bans';
1718
import history from './plugins/history';
1819
import acl from './plugins/acl';
@@ -63,6 +64,7 @@ export default class UWaveServer extends EventEmitter {
6364
this.use(motd());
6465
this.use(playlists());
6566
this.use(users());
67+
this.use(avatars());
6668
this.use(bans());
6769
this.use(history());
6870
this.use(acl());

src/models/User.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export default function userModel() {
8080
return uw.users.updatePassword(this, password);
8181
}
8282

83+
setAvatar(avatar: string): Promise {
84+
return uw.users.updateUser(this, { avatar });
85+
}
86+
8387
getPlaylists(): Promise<Array> {
8488
return uw.playlists.getUserPlaylists(this);
8589
}

src/plugins/avatars.js

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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

Comments
 (0)