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

u-wave / core / 11980840475

22 Nov 2024 10:04PM UTC coverage: 78.492% (-1.7%) from 80.158%
11980840475

Pull #637

github

goto-bus-stop
ci: add node 22
Pull Request #637: Switch to a relational database

757 of 912 branches covered (83.0%)

Branch coverage included in aggregate %.

2001 of 2791 new or added lines in 52 files covered. (71.69%)

9 existing lines in 7 files now uncovered.

8666 of 11093 relevant lines covered (78.12%)

70.72 hits per line

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

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

1✔
10
const { pick, omit } = lodash;
1✔
11

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

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

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

1✔
34
class UsersRepository {
92✔
35
  #uw;
92✔
36

92✔
37
  #logger;
92✔
38

92✔
39
  /**
92✔
40
   * @param {import('../Uwave.js').default} uw
92✔
41
   */
92✔
42
  constructor(uw) {
92✔
43
    this.#uw = uw;
92✔
44
    this.#logger = uw.logger.child({ ns: 'uwave:users' });
92✔
45
  }
92✔
46

92✔
47
  /**
92✔
48
   * @param {string} [filter]
92✔
49
   * @param {{ offset?: number, limit?: number }} [pagination]
92✔
50
   */
92✔
51
  async getUsers(filter, pagination = {}) {
92✔
52
    const { db } = this.#uw;
1✔
53

1✔
54
    const {
1✔
55
      offset = 0,
1✔
56
      limit = 50,
1✔
57
    } = pagination;
1✔
58

1✔
59
    let query = db.selectFrom('users')
1✔
60
      .select([
1✔
61
        'users.id',
1✔
62
        'users.username',
1✔
63
        'users.slug',
1✔
64
        'users.activePlaylistID',
1✔
65
        'users.pendingActivation',
1✔
66
        'users.createdAt',
1✔
67
        'users.updatedAt',
1✔
68
        (eb) => avatarColumn(eb).as('avatar'),
1✔
69
        (eb) => userRolesColumn(eb).as('roles'),
1✔
70
      ])
1✔
71
      .offset(offset)
1✔
72
      .limit(limit);
1✔
73
    if (filter != null) {
1!
NEW
74
      query = query.where('username', 'like', `%${filter}%`);
×
UNCOV
75
    }
×
76

1✔
77
    const totalQuery = db.selectFrom('users')
1✔
78
      .select((eb) => eb.fn.countAll().as('count'))
1✔
79
      .executeTakeFirstOrThrow();
1✔
80

1✔
81
    const filteredQuery = filter == null ? totalQuery : db.selectFrom('users')
1!
NEW
82
      .select((eb) => eb.fn.countAll().as('count'))
×
NEW
83
      .where('username', 'like', `%${filter}%`)
×
84
      .executeTakeFirstOrThrow();
1✔
85

1✔
86
    const [
1✔
87
      users,
1✔
88
      filtered,
1✔
89
      total,
1✔
90
    ] = await Promise.all([
1✔
91
      query.execute(),
1✔
92
      filteredQuery,
1✔
93
      totalQuery,
1✔
94
    ]);
1✔
95

1✔
96
    return new Page(users, {
1✔
97
      pageSize: limit,
1✔
98
      filtered: Number(filtered.count),
1✔
99
      total: Number(total.count),
1✔
100
      current: { offset, limit },
1✔
101
      next: offset + limit <= Number(total.count) ? { offset: offset + limit, limit } : null,
1!
102
      previous: offset > 0
1✔
103
        ? { offset: Math.max(offset - limit, 0), limit }
1!
104
        : null,
1✔
105
    });
1✔
106
  }
1✔
107

92✔
108
  /**
92✔
109
   * Get a user object by ID.
92✔
110
   *
92✔
111
   * @param {UserID} id
92✔
112
   * @param {import('../schema.js').Kysely} [tx]
92✔
113
   */
92✔
114
  async getUser(id, tx) {
92✔
115
    const [user] = await this.getUsersByIds([id], tx);
184✔
116
    return user ?? null;
184!
117
  }
184✔
118

92✔
119
  /**
92✔
120
   * @param {UserID[]} ids
92✔
121
   * @param {import('../schema.js').Kysely} [tx]
92✔
122
   */
92✔
123
  async getUsersByIds(ids, tx = this.#uw.db) {
92✔
124
    const users = await tx.selectFrom('users')
185✔
125
      .where('id', 'in', ids)
185✔
126
      .select([
185✔
127
        'users.id',
185✔
128
        'users.username',
185✔
129
        'users.slug',
185✔
130
        'users.activePlaylistID',
185✔
131
        'users.pendingActivation',
185✔
132
        'users.createdAt',
185✔
133
        'users.updatedAt',
185✔
134
        (eb) => avatarColumn(eb).as('avatar'),
185✔
135
        (eb) => userRolesColumn(eb).as('roles'),
185✔
136
      ])
185✔
137
      .execute();
185✔
138

185✔
139
    for (const user of users) {
185✔
140
      const roles = /** @type {string[]} */ (JSON.parse(
186✔
141
        /** @type {string} */ (/** @type {unknown} */ (user.roles)),
186✔
142
      ));
186✔
143
      Object.assign(user, { roles });
186✔
144
    }
186✔
145

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

92✔
149
  /**
92✔
150
   * @typedef {object} LocalLoginOptions
92✔
151
   * @prop {string} email
92✔
152
   * @prop {string} password
92✔
153
   * @typedef {object} SocialLoginOptions
92✔
154
   * @prop {import('passport').Profile} profile
92✔
155
   * @typedef {LocalLoginOptions & { type: 'local' }} DiscriminatedLocalLoginOptions
92✔
156
   * @typedef {SocialLoginOptions & { type: string }} DiscriminatedSocialLoginOptions
92✔
157
   * @param {DiscriminatedLocalLoginOptions | DiscriminatedSocialLoginOptions} options
92✔
158
   * @returns {Promise<User>}
92✔
159
   */
92✔
160
  login({ type, ...params }) {
92✔
161
    if (type === 'local') {
×
162
      // @ts-expect-error TS2345: Pinky promise not to use 'local' name for custom sources
×
163
      return this.localLogin(params);
×
164
    }
×
165
    // @ts-expect-error TS2345: Pinky promise not to use 'local' name for custom sources
×
166
    return this.socialLogin(type, params);
×
167
  }
×
168

92✔
169
  /**
92✔
170
   * @param {LocalLoginOptions} options
92✔
171
   */
92✔
172
  async localLogin({ email, password }) {
92✔
NEW
173
    const user = await this.#uw.db.selectFrom('users')
×
NEW
174
      .where('email', '=', email)
×
NEW
175
      .select([
×
NEW
176
        'users.id',
×
NEW
177
        'users.username',
×
NEW
178
        'users.slug',
×
NEW
179
        (eb) => avatarColumn(eb).as('avatar'),
×
NEW
180
        'users.activePlaylistID',
×
NEW
181
        'users.pendingActivation',
×
NEW
182
        'users.createdAt',
×
NEW
183
        'users.updatedAt',
×
NEW
184
        'users.password',
×
NEW
185
      ])
×
NEW
186
      .executeTakeFirst();
×
NEW
187
    if (!user) {
×
188
      throw new UserNotFoundError({ email });
×
189
    }
×
190

×
NEW
191
    if (!user.password) {
×
NEW
192
      throw new IncorrectPasswordError();
×
NEW
193
    }
×
NEW
194

×
NEW
195
    const correct = await bcrypt.compare(password, user.password);
×
196
    if (!correct) {
×
197
      throw new IncorrectPasswordError();
×
198
    }
×
199

×
NEW
200
    return omit(user, 'password');
×
201
  }
×
202

92✔
203
  /**
92✔
204
   * @param {string} type
92✔
205
   * @param {SocialLoginOptions} options
92✔
206
   * @returns {Promise<User>}
92✔
207
   */
92✔
208
  async socialLogin(type, { profile }) {
92✔
209
    const user = {
×
210
      type,
×
211
      id: profile.id,
×
212
      username: profile.displayName,
×
213
      avatar: profile.photos && profile.photos.length > 0 ? profile.photos[0].value : undefined,
×
214
    };
×
NEW
215
    return this.findOrCreateSocialUser(user);
×
216
  }
×
217

92✔
218
  /**
92✔
219
   * @typedef {object} FindOrCreateSocialUserOptions
92✔
220
   * @prop {string} type
92✔
221
   * @prop {string} id
92✔
222
   * @prop {string} username
92✔
223
   * @prop {string} [avatar]
92✔
224
   * @param {FindOrCreateSocialUserOptions} options
92✔
225
   * @returns {Promise<User>}
92✔
226
   */
92✔
227
  async findOrCreateSocialUser({
92✔
228
    type,
×
229
    id,
×
230
    username,
×
231
    avatar,
×
232
  }) {
×
NEW
233
    const { db } = this.#uw;
×
234

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

×
NEW
237
    const user = await db.transaction().execute(async (tx) => {
×
NEW
238
      const auth = await tx.selectFrom('authServices')
×
NEW
239
        .innerJoin('users', 'users.id', 'authServices.userID')
×
NEW
240
        .where('service', '=', type)
×
NEW
241
        .where('serviceID', '=', id)
×
NEW
242
        .select([
×
NEW
243
          'authServices.service',
×
NEW
244
          'authServices.serviceID',
×
NEW
245
          'authServices.serviceAvatar',
×
NEW
246
          'users.id',
×
NEW
247
          'users.username',
×
NEW
248
          'users.slug',
×
NEW
249
          'users.activePlaylistID',
×
NEW
250
          'users.pendingActivation',
×
NEW
251
          'users.createdAt',
×
NEW
252
          'users.updatedAt',
×
NEW
253
        ])
×
NEW
254
        .executeTakeFirst();
×
NEW
255

×
NEW
256
      if (auth) {
×
NEW
257
        if (avatar && auth.serviceAvatar !== avatar) {
×
NEW
258
          auth.serviceAvatar = avatar;
×
259
        }
×
260

×
NEW
261
        return Object.assign(
×
NEW
262
          pick(auth, ['id', 'username', 'slug', 'activePlaylistID', 'pendingActivation', 'createdAt', 'updatedAt']),
×
NEW
263
          { avatar: null },
×
NEW
264
        );
×
NEW
265
      } else {
×
NEW
266
        const user = await tx.insertInto('users')
×
NEW
267
          .values({
×
NEW
268
            id: /** @type {UserID} */ (randomUUID()),
×
NEW
269
            username: username ? username.replace(/\s/g, '') : `${type}.${id}`,
×
NEW
270
            slug: slugify(username),
×
NEW
271
            pendingActivation: true,
×
NEW
272
            avatar,
×
NEW
273
          })
×
NEW
274
          .returningAll()
×
NEW
275
          .executeTakeFirstOrThrow();
×
NEW
276

×
NEW
277
        await tx.insertInto('authServices')
×
NEW
278
          .values({
×
NEW
279
            userID: user.id,
×
NEW
280
            service: type,
×
NEW
281
            serviceID: id,
×
NEW
282
            serviceAvatar: avatar,
×
NEW
283
          })
×
NEW
284
          .executeTakeFirstOrThrow();
×
NEW
285

×
NEW
286
        this.#uw.publish('user:create', {
×
NEW
287
          user: user.id,
×
NEW
288
          auth: { type, id },
×
NEW
289
        });
×
NEW
290

×
NEW
291
        return user;
×
NEW
292
      }
×
NEW
293
    });
×
294

×
NEW
295
    return user;
×
296
  }
×
297

92✔
298
  /**
92✔
299
   * @param {{ username: string, email: string, password: string }} props
92✔
300
   */
92✔
301
  async createUser({
92✔
302
    username, email, password,
4✔
303
  }) {
4✔
304
    const { acl, db } = this.#uw;
4✔
305

4✔
306
    this.#logger.info({ username, email: email.toLowerCase() }, 'create user');
4✔
307

4✔
308
    const hash = await encryptPassword(password);
4✔
309

4✔
310
    const user = await db.insertInto('users')
4✔
311
      .values({
4✔
312
        id: /** @type {UserID} */ (randomUUID()),
4✔
313
        username,
4✔
314
        email,
4✔
315
        password: hash,
4✔
316
        slug: slugify(username),
4✔
317
        pendingActivation: /** @type {boolean} */ (/** @type {unknown} */ (0)),
4✔
318
      })
4✔
319
      .returning([
4✔
320
        'users.id',
4✔
321
        'users.username',
4✔
322
        'users.slug',
4✔
323
        (eb) => avatarColumn(eb).as('avatar'),
4✔
324
        'users.activePlaylistID',
4✔
325
        'users.pendingActivation',
4✔
326
        'users.createdAt',
4✔
327
        'users.updatedAt',
4✔
328
      ])
4✔
329
      .executeTakeFirstOrThrow();
4✔
330

4✔
331
    const roles = ['user'];
4✔
332
    await acl.allow(user, roles);
4✔
333

4✔
334
    this.#uw.publish('user:create', {
4✔
335
      user: user.id,
4✔
336
      auth: { type: 'local', email: email.toLowerCase() },
4✔
337
    });
4✔
338

4✔
339
    return Object.assign(user, { roles });
4✔
340
  }
4✔
341

92✔
342
  /**
92✔
343
   * @param {UserID} id
92✔
344
   * @param {string} password
92✔
345
   */
92✔
346
  async updatePassword(id, password) {
92✔
NEW
347
    const { db } = this.#uw;
×
348

×
349
    const hash = await encryptPassword(password);
×
NEW
350
    const result = await db.updateTable('users')
×
NEW
351
      .where('id', '=', id)
×
NEW
352
      .set({ password: hash })
×
NEW
353
      .executeTakeFirst();
×
NEW
354
    if (Number(result.numUpdatedRows) === 0) {
×
NEW
355
      throw new UserNotFoundError({ id });
×
356
    }
×
357
  }
×
358

92✔
359
  /**
92✔
360
   * @param {UserID} id
92✔
361
   * @param {Partial<Pick<User, 'username'>>} update
92✔
362
   * @param {{ moderator?: User }} [options]
92✔
363
   */
92✔
364
  async updateUser(id, update = {}, options = {}) {
92✔
NEW
365
    const { db } = this.#uw;
×
NEW
366

×
367
    const user = await this.getUser(id);
×
368
    if (!user) throw new UserNotFoundError({ id });
×
369

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

×
NEW
372
    const moderator = options.moderator;
×
373

×
NEW
374
    /** @type {typeof update} */
×
375
    const old = {};
×
376
    Object.keys(update).forEach((key) => {
×
NEW
377
      // FIXME We should somehow make sure that the type of `key` extends `keyof User` here.
×
378
      // @ts-expect-error TS7053
×
379
      old[key] = user[key];
×
380
    });
×
381
    Object.assign(user, update);
×
382

×
NEW
383
    const updatesFromDatabase = await db.updateTable('users')
×
NEW
384
      .where('id', '=', id)
×
NEW
385
      .set(update)
×
NEW
386
      .returning(['username'])
×
NEW
387
      .executeTakeFirst();
×
NEW
388
    if (!updatesFromDatabase) {
×
NEW
389
      throw new UserNotFoundError({ id });
×
NEW
390
    }
×
NEW
391
    Object.assign(user, updatesFromDatabase);
×
392

×
393
    // Take updated keys from the Model again,
×
394
    // as it may apply things like Unicode normalization on the values.
×
395
    Object.keys(update).forEach((key) => {
×
396
      // @ts-expect-error Infeasible to statically check properties here
×
397
      // Hopefully the caller took care
×
398
      update[key] = user[key];
×
399
    });
×
400

×
401
    this.#uw.publish('user:update', {
×
402
      userID: user.id,
×
403
      moderatorID: moderator ? moderator.id : null,
×
404
      old,
×
405
      new: update,
×
406
    });
×
407

×
408
    return user;
×
409
  }
×
410
}
92✔
411

1✔
412
/**
1✔
413
 * @param {import('../Uwave.js').default} uw
1✔
414
 */
1✔
415
async function usersPlugin(uw) {
92✔
416
  uw.users = new UsersRepository(uw);
92✔
417
}
92✔
418

1✔
419
export default usersPlugin;
1✔
420
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