• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 5432206291

01 Jul 2023 05:18PM UTC coverage: 80.492% (+0.02%) from 80.474%
5432206291

Pull #571

github

web-flow
Merge a84b96ad5 into cd54c76a5
Pull Request #571: update supported node.js versions

640 of 775 branches covered (82.58%)

Branch coverage included in aggregate %.

230 of 230 new or added lines in 45 files covered. (100.0%)

8297 of 10328 relevant lines covered (80.34%)

89.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

55.85
/src/plugins/users.js
1
import bcrypt from 'bcryptjs';
2✔
2
import escapeStringRegExp from 'escape-string-regexp';
2✔
3
import Page from '../Page.js';
2✔
4
import { IncorrectPasswordError, UserNotFoundError } from '../errors/index.js';
2✔
5

2✔
6
/**
2✔
7
 * @typedef {import('../models/index.js').User} User
2✔
8
 * @typedef {import('../models/index.js').Authentication} Authentication
2✔
9
 */
2✔
10

2✔
11
/**
2✔
12
 * @param {string} password
2✔
13
 */
2✔
14
function encryptPassword(password) {
8✔
15
  return bcrypt.hash(password, 10);
8✔
16
}
8✔
17

2✔
18
/**
2✔
19
 * @param {User} user
2✔
20
 */
2✔
21
function getDefaultAvatar(user) {
8✔
22
  return `https://sigil.u-wave.net/${user.id}`;
8✔
23
}
8✔
24

2✔
25
class UsersRepository {
184✔
26
  #uw;
184✔
27

184✔
28
  #logger;
184✔
29

184✔
30
  /**
184✔
31
   * @param {import('../Uwave.js').default} uw
184✔
32
   */
184✔
33
  constructor(uw) {
184✔
34
    this.#uw = uw;
184✔
35
    this.#logger = uw.logger.child({ ns: 'uwave:users' });
184✔
36
  }
184✔
37

184✔
38
  /**
184✔
39
   * @param {string} [filter]
184✔
40
   * @param {{ offset?: number, limit?: number }} [pagination]
184✔
41
   */
184✔
42
  async getUsers(filter, pagination = {}) {
184✔
43
    const { User } = this.#uw.models;
2✔
44

2✔
45
    const {
2✔
46
      offset = 0,
2✔
47
      limit = 50,
2✔
48
    } = pagination;
2✔
49

2✔
50
    const query = User.find()
2✔
51
      .skip(offset)
2✔
52
      .limit(limit);
2✔
53
    let queryFilter = null;
2✔
54

2✔
55
    if (filter) {
2!
56
      queryFilter = {
×
57
        username: new RegExp(escapeStringRegExp(filter)),
×
58
      };
×
59
      query.where(queryFilter);
×
60
    }
×
61

2✔
62
    const totalPromise = User.estimatedDocumentCount().exec();
2✔
63

2✔
64
    const [
2✔
65
      users,
2✔
66
      filtered,
2✔
67
      total,
2✔
68
    ] = await Promise.all([
2✔
69
      query,
2✔
70
      queryFilter ? User.find().where(queryFilter).countDocuments() : totalPromise,
2!
71
      totalPromise,
2✔
72
    ]);
2✔
73

2✔
74
    return new Page(users, {
2✔
75
      pageSize: limit,
2✔
76
      filtered,
2✔
77
      total,
2✔
78
      current: { offset, limit },
2✔
79
      next: offset + limit <= total ? { offset: offset + limit, limit } : null,
2!
80
      previous: offset > 0
2✔
81
        ? { offset: Math.max(offset - limit, 0), limit }
2!
82
        : null,
2✔
83
    });
2✔
84
  }
2✔
85

184✔
86
  /**
184✔
87
   * Get a user object by ID.
184✔
88
   *
184✔
89
   * @param {import('mongodb').ObjectId|string} id
184✔
90
   * @returns {Promise<User|null>}
184✔
91
   */
184✔
92
  async getUser(id) {
184✔
93
    const { User } = this.#uw.models;
352✔
94
    const user = await User.findById(id);
352✔
95

352✔
96
    return user;
352✔
97
  }
352✔
98

184✔
99
  /**
184✔
100
   * @typedef {object} LocalLoginOptions
184✔
101
   * @prop {string} email
184✔
102
   * @prop {string} password
184✔
103
   *
184✔
104
   * @typedef {object} SocialLoginOptions
184✔
105
   * @prop {import('passport').Profile} profile
184✔
106
   *
184✔
107
   * @typedef {LocalLoginOptions & { type: 'local' }} DiscriminatedLocalLoginOptions
184✔
108
   * @typedef {SocialLoginOptions & { type: string }} DiscriminatedSocialLoginOptions
184✔
109
   *
184✔
110
   * @param {DiscriminatedLocalLoginOptions | DiscriminatedSocialLoginOptions} options
184✔
111
   * @returns {Promise<User>}
184✔
112
   */
184✔
113
  login({ type, ...params }) {
184✔
114
    if (type === 'local') {
×
115
      // @ts-expect-error TS2345: Pinky promise not to use 'local' name for custom sources
×
116
      return this.localLogin(params);
×
117
    }
×
118
    // @ts-expect-error TS2345: Pinky promise not to use 'local' name for custom sources
×
119
    return this.socialLogin(type, params);
×
120
  }
×
121

184✔
122
  /**
184✔
123
   * @param {LocalLoginOptions} options
184✔
124
   * @returns {Promise<User>}
184✔
125
   */
184✔
126
  async localLogin({ email, password }) {
184✔
127
    const { Authentication } = this.#uw.models;
×
128

×
129
    /** @type {null | (Authentication & { user: User })} */
×
130
    const auth = /** @type {any} */ (await Authentication.findOne({
×
131
      email: email.toLowerCase(),
×
132
    }).populate('user').exec());
×
133
    if (!auth || !auth.hash) {
×
134
      throw new UserNotFoundError({ email });
×
135
    }
×
136

×
137
    const correct = await bcrypt.compare(password, auth.hash);
×
138
    if (!correct) {
×
139
      throw new IncorrectPasswordError();
×
140
    }
×
141

×
142
    return auth.user;
×
143
  }
×
144

184✔
145
  /**
184✔
146
   * @param {string} type
184✔
147
   * @param {SocialLoginOptions} options
184✔
148
   * @returns {Promise<User>}
184✔
149
   */
184✔
150
  async socialLogin(type, { profile }) {
184✔
151
    const user = {
×
152
      type,
×
153
      id: profile.id,
×
154
      username: profile.displayName,
×
155
      avatar: profile.photos && profile.photos.length > 0 ? profile.photos[0].value : undefined,
×
156
    };
×
157
    return this.#uw.users.findOrCreateSocialUser(user);
×
158
  }
×
159

184✔
160
  /**
184✔
161
   * @typedef {object} FindOrCreateSocialUserOptions
184✔
162
   * @prop {string} type
184✔
163
   * @prop {string} id
184✔
164
   * @prop {string} username
184✔
165
   * @prop {string} [avatar]
184✔
166
   *
184✔
167
   * @param {FindOrCreateSocialUserOptions} options
184✔
168
   * @returns {Promise<User>}
184✔
169
   */
184✔
170
  async findOrCreateSocialUser({
184✔
171
    type,
×
172
    id,
×
173
    username,
×
174
    avatar,
×
175
  }) {
×
176
    const { User, Authentication } = this.#uw.models;
×
177

×
178
    this.#logger.info({ type, id }, 'find or create social');
×
179

×
180
    // we need this type assertion because the `user` property actually contains
×
181
    // an ObjectId in this return value. We are definitely filling in a User object
×
182
    // below before using this variable.
×
183
    /** @type {null | (Omit<Authentication, 'user'> & { user: User })} */
×
184
    let auth = await Authentication.findOne({ type, id });
×
185
    if (auth) {
×
186
      await auth.populate('user');
×
187

×
188
      if (avatar && auth.avatar !== avatar) {
×
189
        auth.avatar = avatar;
×
190
        await auth.save();
×
191
      }
×
192
    } else {
×
193
      const user = new User({
×
194
        username: username ? username.replace(/\s/g, '') : `${type}.${id}`,
×
195
        roles: ['user'],
×
196
        pendingActivation: type,
×
197
      });
×
198
      await user.validate();
×
199

×
200
      // @ts-expect-error TS2322: the type check fails because the `user` property actually contains
×
201
      // an ObjectId in this return value. We are definitely filling in a User object below before
×
202
      // using this variable.
×
203
      auth = new Authentication({
×
204
        type,
×
205
        user,
×
206
        id,
×
207
        avatar,
×
208
        // HACK, providing a fake email so we can use `unique: true` on emails
×
209
        email: `${id}@${type}.sociallogin`,
×
210
      });
×
211

×
212
      // Just so typescript knows `auth` is not null here.
×
213
      if (!auth) throw new TypeError();
×
214

×
215
      try {
×
216
        await Promise.all([
×
217
          user.save(),
×
218
          auth.save(),
×
219
        ]);
×
220
      } catch (e) {
×
221
        if (!auth.isNew) {
×
222
          await auth.deleteOne();
×
223
        }
×
224
        await user.deleteOne();
×
225
        throw e;
×
226
      }
×
227

×
228
      this.#uw.publish('user:create', {
×
229
        user: user.id,
×
230
        auth: { type, id },
×
231
      });
×
232
    }
×
233

×
234
    return auth.user;
×
235
  }
×
236

184✔
237
  /**
184✔
238
   * @param {{ username: string, email: string, password: string }} props
184✔
239
   * @returns {Promise<User>}
184✔
240
   */
184✔
241
  async createUser({
184✔
242
    username, email, password,
8✔
243
  }) {
8✔
244
    const { User, Authentication } = this.#uw.models;
8✔
245

8✔
246
    this.#logger.info({ username, email: email.toLowerCase() }, 'create user');
8✔
247

8✔
248
    const hash = await encryptPassword(password);
8✔
249

8✔
250
    const user = new User({
8✔
251
      username,
8✔
252
      roles: ['user'],
8✔
253
    });
8✔
254
    await user.validate();
8✔
255

8✔
256
    const auth = new Authentication({
8✔
257
      type: 'local',
8✔
258
      user,
8✔
259
      email: email.toLowerCase(),
8✔
260
      hash,
8✔
261
    });
8✔
262

8✔
263
    try {
8✔
264
      await Promise.all([
8✔
265
        user.save(),
8✔
266
        auth.save(),
8✔
267
      ]);
8✔
268
      // Two-stage saving to let mongodb decide the user ID before we generate an avatar URL.
8✔
269
      user.avatar = getDefaultAvatar(user);
8✔
270
      await user.save();
8✔
271
    } catch (e) {
8!
272
      if (!auth.isNew) {
×
273
        await auth.deleteOne();
×
274
      }
×
275
      await user.deleteOne();
×
276
      throw e;
×
277
    }
×
278

8✔
279
    this.#uw.publish('user:create', {
8✔
280
      user: user.id,
8✔
281
      auth: { type: 'local', email: email.toLowerCase() },
8✔
282
    });
8✔
283

8✔
284
    return user;
8✔
285
  }
8✔
286

184✔
287
  /**
184✔
288
   * @param {import('mongodb').ObjectId} id
184✔
289
   * @param {string} password
184✔
290
   */
184✔
291
  async updatePassword(id, password) {
184✔
292
    const { Authentication } = this.#uw.models;
×
293

×
294
    const user = await this.getUser(id);
×
295
    if (!user) throw new UserNotFoundError({ id });
×
296

×
297
    const hash = await encryptPassword(password);
×
298

×
299
    const auth = await Authentication.findOneAndUpdate({
×
300
      // TODO re enable once a migrations thing is set up so that all existing
×
301
      // records can be updated to add this.
×
302
      // type: 'local',
×
303
      user: user._id,
×
304
    }, { hash });
×
305

×
306
    if (!auth) {
×
307
      throw new UserNotFoundError({ id: user.id });
×
308
    }
×
309
  }
×
310

184✔
311
  /**
184✔
312
   * @param {import('mongodb').ObjectId|string} id
184✔
313
   * @param {Record<string, string>} update
184✔
314
   * @param {{ moderator?: User }} [options]
184✔
315
   */
184✔
316
  async updateUser(id, update = {}, options = {}) {
184✔
317
    const user = await this.getUser(id);
×
318
    if (!user) throw new UserNotFoundError({ id });
×
319

×
320
    this.#logger.info({ userId: user.id, update }, 'update user');
×
321

×
322
    const moderator = options && options.moderator;
×
323

×
324
    /** @type {Record<string, string>} */
×
325
    const old = {};
×
326
    Object.keys(update).forEach((key) => {
×
327
      // FIXME We should somehow make sure that the type of `key` extends `keyof LeanUser` here.
×
328
      // @ts-expect-error TS7053
×
329
      old[key] = user[key];
×
330
    });
×
331
    Object.assign(user, update);
×
332

×
333
    await user.save();
×
334

×
335
    // Take updated keys from the Model again,
×
336
    // as it may apply things like Unicode normalization on the values.
×
337
    Object.keys(update).forEach((key) => {
×
338
      // @ts-expect-error Infeasible to statically check properties here
×
339
      // Hopefully the caller took care
×
340
      update[key] = user[key];
×
341
    });
×
342

×
343
    this.#uw.publish('user:update', {
×
344
      userID: user.id,
×
345
      moderatorID: moderator ? moderator.id : null,
×
346
      old,
×
347
      new: update,
×
348
    });
×
349

×
350
    return user;
×
351
  }
×
352
}
184✔
353

2✔
354
/**
2✔
355
 * @param {import('../Uwave.js').default} uw
2✔
356
 */
2✔
357
async function usersPlugin(uw) {
184✔
358
  uw.users = new UsersRepository(uw);
184✔
359
}
184✔
360

2✔
361
export default usersPlugin;
2✔
362
export { UsersRepository };
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc