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

u-wave / core / 5430963701

01 Jul 2023 12:14PM UTC coverage: 80.474%. Remained the same
5430963701

Pull #571

github

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

640 of 775 branches covered (82.58%)

Branch coverage included in aggregate %.

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

8287 of 10318 relevant lines covered (80.32%)

89.15 hits per line

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

55.73
/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
 */
2✔
9

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

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

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

184✔
27
  #logger;
184✔
28

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

×
332
    await user.save();
×
333

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

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

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

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

2✔
360
export default usersPlugin;
2✔
361
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