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

u-wave / core / 11988045291

23 Nov 2024 02:43PM UTC coverage: 78.577%. First build
11988045291

push

github

goto-bus-stop
Translate UNIQUE_CONSTRAINT errors on user signup

761 of 916 branches covered (83.08%)

Branch coverage included in aggregate %.

44 of 46 new or added lines in 2 files covered. (95.65%)

8669 of 11085 relevant lines covered (78.2%)

71.56 hits per line

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

62.11
/src/plugins/users.js
1
import { randomUUID } from 'crypto';
1✔
2
import lodash from 'lodash';
1✔
3
import { sql } from 'kysely';
1✔
4
import { slugify } from 'transliteration';
1✔
5
import bcrypt from 'bcryptjs';
1✔
6
import Page from '../Page.js';
1✔
7
import {
1✔
8
  IncorrectPasswordError, UsedEmailError, UsedUsernameError, UserNotFoundError,
1✔
9
} from '../errors/index.js';
1✔
10
import { jsonGroupArray } from '../utils/sqlite.js';
1✔
11

1✔
12
const { pick, omit } = lodash;
1✔
13

1✔
14
/**
1✔
15
 * @typedef {import('../schema.js').User} User
1✔
16
 * @typedef {import('../schema.js').UserID} UserID
1✔
17
 */
1✔
18

1✔
19
/**
1✔
20
 * @param {string} password
1✔
21
 */
1✔
22
function encryptPassword(password) {
8✔
23
  return bcrypt.hash(password, 10);
8✔
24
}
8✔
25

1✔
26
/** @param {import('kysely').ExpressionBuilder<import('../schema.js').Database, 'users'>} eb */
1✔
27
const userRolesColumn = (eb) => eb.selectFrom('userRoles')
1✔
28
  .where('userRoles.userID', '=', eb.ref('users.id'))
186✔
29
  .select((sb) => jsonGroupArray(sb.ref('userRoles.role')).as('roles'));
1✔
30
/** @param {import('kysely').ExpressionBuilder<import('../schema.js').Database, 'users'>} eb */
1✔
31
const avatarColumn = (eb) => eb.fn.coalesce(
1✔
32
  'users.avatar',
194✔
33
  /** @type {import('kysely').RawBuilder<string>} */ (sql`concat('https://sigil.u-wave.net/', ${eb.ref('users.id')})`),
194✔
34
);
1✔
35

1✔
36
/**
1✔
37
 * @param {unknown} err
1✔
38
 * @returns {never}
1✔
39
 */
1✔
40
function rethrowInsertError(err) {
2✔
41
  if (err instanceof Error && 'code' in err && err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
2✔
42
    if (err.message.includes('users.email')) {
2✔
43
      throw new UsedEmailError();
1✔
44
    }
1✔
45
    if (err.message.includes('users.username') || err.message.includes('users.slug')) {
2✔
46
      throw new UsedUsernameError();
1✔
47
    }
1✔
48
  }
2✔
NEW
49
  throw err;
×
50
}
2✔
51

1✔
52
class UsersRepository {
94✔
53
  #uw;
94✔
54

94✔
55
  #logger;
94✔
56

94✔
57
  /**
94✔
58
   * @param {import('../Uwave.js').default} uw
94✔
59
   */
94✔
60
  constructor(uw) {
94✔
61
    this.#uw = uw;
94✔
62
    this.#logger = uw.logger.child({ ns: 'uwave:users' });
94✔
63
  }
94✔
64

94✔
65
  /**
94✔
66
   * @param {string} [filter]
94✔
67
   * @param {{ offset?: number, limit?: number }} [pagination]
94✔
68
   */
94✔
69
  async getUsers(filter, pagination = {}) {
94✔
70
    const { db } = this.#uw;
1✔
71

1✔
72
    const {
1✔
73
      offset = 0,
1✔
74
      limit = 50,
1✔
75
    } = pagination;
1✔
76

1✔
77
    let query = db.selectFrom('users')
1✔
78
      .select([
1✔
79
        'users.id',
1✔
80
        'users.username',
1✔
81
        'users.slug',
1✔
82
        'users.activePlaylistID',
1✔
83
        'users.pendingActivation',
1✔
84
        'users.createdAt',
1✔
85
        'users.updatedAt',
1✔
86
        (eb) => avatarColumn(eb).as('avatar'),
1✔
87
        (eb) => userRolesColumn(eb).as('roles'),
1✔
88
      ])
1✔
89
      .offset(offset)
1✔
90
      .limit(limit);
1✔
91
    if (filter != null) {
1!
92
      query = query.where('username', 'like', `%${filter}%`);
×
93
    }
×
94

1✔
95
    const totalQuery = db.selectFrom('users')
1✔
96
      .select((eb) => eb.fn.countAll().as('count'))
1✔
97
      .executeTakeFirstOrThrow();
1✔
98

1✔
99
    const filteredQuery = filter == null ? totalQuery : db.selectFrom('users')
1!
100
      .select((eb) => eb.fn.countAll().as('count'))
×
101
      .where('username', 'like', `%${filter}%`)
×
102
      .executeTakeFirstOrThrow();
1✔
103

1✔
104
    const [
1✔
105
      users,
1✔
106
      filtered,
1✔
107
      total,
1✔
108
    ] = await Promise.all([
1✔
109
      query.execute(),
1✔
110
      filteredQuery,
1✔
111
      totalQuery,
1✔
112
    ]);
1✔
113

1✔
114
    return new Page(users, {
1✔
115
      pageSize: limit,
1✔
116
      filtered: Number(filtered.count),
1✔
117
      total: Number(total.count),
1✔
118
      current: { offset, limit },
1✔
119
      next: offset + limit <= Number(total.count) ? { offset: offset + limit, limit } : null,
1!
120
      previous: offset > 0
1✔
121
        ? { offset: Math.max(offset - limit, 0), limit }
1!
122
        : null,
1✔
123
    });
1✔
124
  }
1✔
125

94✔
126
  /**
94✔
127
   * Get a user object by ID.
94✔
128
   *
94✔
129
   * @param {UserID} id
94✔
130
   * @param {import('../schema.js').Kysely} [tx]
94✔
131
   */
94✔
132
  async getUser(id, tx) {
94✔
133
    const [user] = await this.getUsersByIds([id], tx);
184✔
134
    return user ?? null;
184!
135
  }
184✔
136

94✔
137
  /**
94✔
138
   * @param {UserID[]} ids
94✔
139
   * @param {import('../schema.js').Kysely} [tx]
94✔
140
   */
94✔
141
  async getUsersByIds(ids, tx = this.#uw.db) {
94✔
142
    const users = await tx.selectFrom('users')
185✔
143
      .where('id', 'in', ids)
185✔
144
      .select([
185✔
145
        'users.id',
185✔
146
        'users.username',
185✔
147
        'users.slug',
185✔
148
        'users.activePlaylistID',
185✔
149
        'users.pendingActivation',
185✔
150
        'users.createdAt',
185✔
151
        'users.updatedAt',
185✔
152
        (eb) => avatarColumn(eb).as('avatar'),
185✔
153
        (eb) => userRolesColumn(eb).as('roles'),
185✔
154
      ])
185✔
155
      .execute();
185✔
156

185✔
157
    for (const user of users) {
185✔
158
      const roles = /** @type {string[]} */ (JSON.parse(
186✔
159
        /** @type {string} */ (/** @type {unknown} */ (user.roles)),
186✔
160
      ));
186✔
161
      Object.assign(user, { roles });
186✔
162
    }
186✔
163

185✔
164
    return /** @type {import('type-fest').SetNonNullable<(typeof users)[0], 'roles'>[]} */ (users);
185✔
165
  }
185✔
166

94✔
167
  /**
94✔
168
   * @typedef {object} LocalLoginOptions
94✔
169
   * @prop {string} email
94✔
170
   * @prop {string} password
94✔
171
   * @typedef {object} SocialLoginOptions
94✔
172
   * @prop {import('passport').Profile} profile
94✔
173
   * @typedef {LocalLoginOptions & { type: 'local' }} DiscriminatedLocalLoginOptions
94✔
174
   * @typedef {SocialLoginOptions & { type: string }} DiscriminatedSocialLoginOptions
94✔
175
   * @param {DiscriminatedLocalLoginOptions | DiscriminatedSocialLoginOptions} options
94✔
176
   * @returns {Promise<User>}
94✔
177
   */
94✔
178
  login({ type, ...params }) {
94✔
179
    if (type === 'local') {
×
180
      // @ts-expect-error TS2345: Pinky promise not to use 'local' name for custom sources
×
181
      return this.localLogin(params);
×
182
    }
×
183
    // @ts-expect-error TS2345: Pinky promise not to use 'local' name for custom sources
×
184
    return this.socialLogin(type, params);
×
185
  }
×
186

94✔
187
  /**
94✔
188
   * @param {LocalLoginOptions} options
94✔
189
   */
94✔
190
  async localLogin({ email, password }) {
94✔
191
    const user = await this.#uw.db.selectFrom('users')
×
192
      .where('email', '=', email)
×
193
      .select([
×
194
        'users.id',
×
195
        'users.username',
×
196
        'users.slug',
×
197
        (eb) => avatarColumn(eb).as('avatar'),
×
198
        'users.activePlaylistID',
×
199
        'users.pendingActivation',
×
200
        'users.createdAt',
×
201
        'users.updatedAt',
×
202
        'users.password',
×
203
      ])
×
204
      .executeTakeFirst();
×
205
    if (!user) {
×
206
      throw new UserNotFoundError({ email });
×
207
    }
×
208

×
209
    if (!user.password) {
×
210
      throw new IncorrectPasswordError();
×
211
    }
×
212

×
213
    const correct = await bcrypt.compare(password, user.password);
×
214
    if (!correct) {
×
215
      throw new IncorrectPasswordError();
×
216
    }
×
217

×
218
    return omit(user, 'password');
×
219
  }
×
220

94✔
221
  /**
94✔
222
   * @param {string} type
94✔
223
   * @param {SocialLoginOptions} options
94✔
224
   * @returns {Promise<User>}
94✔
225
   */
94✔
226
  async socialLogin(type, { profile }) {
94✔
227
    const user = {
×
228
      type,
×
229
      id: profile.id,
×
230
      username: profile.displayName,
×
231
      avatar: profile.photos && profile.photos.length > 0 ? profile.photos[0].value : undefined,
×
232
    };
×
233
    return this.findOrCreateSocialUser(user);
×
234
  }
×
235

94✔
236
  /**
94✔
237
   * @typedef {object} FindOrCreateSocialUserOptions
94✔
238
   * @prop {string} type
94✔
239
   * @prop {string} id
94✔
240
   * @prop {string} username
94✔
241
   * @prop {string} [avatar]
94✔
242
   * @param {FindOrCreateSocialUserOptions} options
94✔
243
   * @returns {Promise<User>}
94✔
244
   */
94✔
245
  async findOrCreateSocialUser({
94✔
246
    type,
×
247
    id,
×
248
    username,
×
249
    avatar,
×
250
  }) {
×
251
    const { db } = this.#uw;
×
252

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

×
255
    const user = await db.transaction().execute(async (tx) => {
×
256
      const auth = await tx.selectFrom('authServices')
×
257
        .innerJoin('users', 'users.id', 'authServices.userID')
×
258
        .where('service', '=', type)
×
259
        .where('serviceID', '=', id)
×
260
        .select([
×
261
          'authServices.service',
×
262
          'authServices.serviceID',
×
263
          'authServices.serviceAvatar',
×
264
          'users.id',
×
265
          'users.username',
×
266
          'users.slug',
×
267
          'users.activePlaylistID',
×
268
          'users.pendingActivation',
×
269
          'users.createdAt',
×
270
          'users.updatedAt',
×
271
        ])
×
272
        .executeTakeFirst();
×
273

×
274
      if (auth) {
×
275
        if (avatar && auth.serviceAvatar !== avatar) {
×
276
          auth.serviceAvatar = avatar;
×
277
        }
×
278

×
279
        return Object.assign(
×
280
          pick(auth, ['id', 'username', 'slug', 'activePlaylistID', 'pendingActivation', 'createdAt', 'updatedAt']),
×
281
          { avatar: null },
×
282
        );
×
283
      } else {
×
284
        const user = await tx.insertInto('users')
×
285
          .values({
×
286
            id: /** @type {UserID} */ (randomUUID()),
×
287
            username: username ? username.replace(/\s/g, '') : `${type}.${id}`,
×
288
            slug: slugify(username),
×
289
            pendingActivation: true,
×
290
            avatar,
×
291
          })
×
292
          .returningAll()
×
293
          .executeTakeFirstOrThrow();
×
294

×
295
        await tx.insertInto('authServices')
×
296
          .values({
×
297
            userID: user.id,
×
298
            service: type,
×
299
            serviceID: id,
×
300
            serviceAvatar: avatar,
×
301
          })
×
302
          .executeTakeFirstOrThrow();
×
303

×
304
        this.#uw.publish('user:create', {
×
305
          user: user.id,
×
306
          auth: { type, id },
×
307
        });
×
308

×
309
        return user;
×
310
      }
×
NEW
311
    }).catch(rethrowInsertError);
×
312

×
313
    return user;
×
314
  }
×
315

94✔
316
  /**
94✔
317
   * @param {{ username: string, email: string, password: string }} props
94✔
318
   */
94✔
319
  async createUser({
94✔
320
    username, email, password,
8✔
321
  }) {
8✔
322
    const { acl, db } = this.#uw;
8✔
323

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

8✔
326
    const hash = await encryptPassword(password);
8✔
327

8✔
328
    const insert = db.insertInto('users')
8✔
329
      .values({
8✔
330
        id: /** @type {UserID} */ (randomUUID()),
8✔
331
        username,
8✔
332
        email,
8✔
333
        password: hash,
8✔
334
        slug: slugify(username),
8✔
335
        pendingActivation: /** @type {boolean} */ (/** @type {unknown} */ (0)),
8✔
336
      })
8✔
337
      .returning([
8✔
338
        'users.id',
8✔
339
        'users.username',
8✔
340
        'users.slug',
8✔
341
        (eb) => avatarColumn(eb).as('avatar'),
8✔
342
        'users.activePlaylistID',
8✔
343
        'users.pendingActivation',
8✔
344
        'users.createdAt',
8✔
345
        'users.updatedAt',
8✔
346
      ]);
8✔
347

8✔
348
    let user;
8✔
349
    try {
8✔
350
      user = await insert.executeTakeFirstOrThrow();
8✔
351
    } catch (err) {
8✔
352
      rethrowInsertError(err);
2✔
353
    }
2✔
354

6✔
355
    const roles = ['user'];
6✔
356
    await acl.allow(user, roles);
6✔
357

6✔
358
    this.#uw.publish('user:create', {
6✔
359
      user: user.id,
6✔
360
      auth: { type: 'local', email: email.toLowerCase() },
6✔
361
    });
6✔
362

6✔
363
    return Object.assign(user, { roles });
6✔
364
  }
8✔
365

94✔
366
  /**
94✔
367
   * @param {UserID} id
94✔
368
   * @param {string} password
94✔
369
   */
94✔
370
  async updatePassword(id, password) {
94✔
371
    const { db } = this.#uw;
×
372

×
373
    const hash = await encryptPassword(password);
×
374
    const result = await db.updateTable('users')
×
375
      .where('id', '=', id)
×
376
      .set({ password: hash })
×
377
      .executeTakeFirst();
×
378
    if (Number(result.numUpdatedRows) === 0) {
×
379
      throw new UserNotFoundError({ id });
×
380
    }
×
381
  }
×
382

94✔
383
  /**
94✔
384
   * @param {UserID} id
94✔
385
   * @param {Partial<Pick<User, 'username'>>} update
94✔
386
   * @param {{ moderator?: User }} [options]
94✔
387
   */
94✔
388
  async updateUser(id, update = {}, options = {}) {
94✔
389
    const { db } = this.#uw;
×
390

×
391
    const user = await this.getUser(id);
×
392
    if (!user) throw new UserNotFoundError({ id });
×
393

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

×
396
    const moderator = options.moderator;
×
397

×
398
    /** @type {typeof update} */
×
399
    const old = {};
×
400
    Object.keys(update).forEach((key) => {
×
401
      // FIXME We should somehow make sure that the type of `key` extends `keyof User` here.
×
402
      // @ts-expect-error TS7053
×
403
      old[key] = user[key];
×
404
    });
×
405
    Object.assign(user, update);
×
406

×
407
    const updatesFromDatabase = await db.updateTable('users')
×
408
      .where('id', '=', id)
×
409
      .set(update)
×
410
      .returning(['username'])
×
411
      .executeTakeFirst();
×
412
    if (!updatesFromDatabase) {
×
413
      throw new UserNotFoundError({ id });
×
414
    }
×
415
    Object.assign(user, updatesFromDatabase);
×
416

×
417
    // Take updated keys from the Model again,
×
418
    // as it may apply things like Unicode normalization on the values.
×
419
    Object.keys(update).forEach((key) => {
×
420
      // @ts-expect-error Infeasible to statically check properties here
×
421
      // Hopefully the caller took care
×
422
      update[key] = user[key];
×
423
    });
×
424

×
425
    this.#uw.publish('user:update', {
×
426
      userID: user.id,
×
427
      moderatorID: moderator ? moderator.id : null,
×
428
      old,
×
429
      new: update,
×
430
    });
×
431

×
432
    return user;
×
433
  }
×
434
}
94✔
435

1✔
436
/**
1✔
437
 * @param {import('../Uwave.js').default} uw
1✔
438
 */
1✔
439
async function usersPlugin(uw) {
94✔
440
  uw.users = new UsersRepository(uw);
94✔
441
}
94✔
442

1✔
443
export default usersPlugin;
1✔
444
export { UsersRepository };
1✔
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

© 2025 Coveralls, Inc