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

u-wave / core / 20158590656

12 Dec 2025 06:37AM UTC coverage: 85.849% (-0.001%) from 85.85%
20158590656

Pull #726

github

web-flow
Merge d9473a292 into b13293cdf
Pull Request #726: Move password reset states into SQLite

969 of 1158 branches covered (83.68%)

Branch coverage included in aggregate %.

48 of 80 new or added lines in 3 files covered. (60.0%)

10157 of 11802 relevant lines covered (86.06%)

97.52 hits per line

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

77.51
/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 { fromJson, 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) {
10✔
23
  return bcrypt.hash(password, 10);
10✔
24
}
10✔
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'))
388✔
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',
401✔
33
  /** @type {import('kysely').RawBuilder<string>} */ (sql`concat('https://sigil.u-wave.net/', ${eb.ref('users.id')})`),
401✔
34
);
1✔
35

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

1✔
54
class UsersRepository {
1✔
55
  #uw;
152✔
56

152✔
57
  #logger;
152✔
58

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

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

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

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

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

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

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

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

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

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

387✔
159
    return users.map((user) => ({
387✔
160
      ...user,
386✔
161
      roles: user.roles != null ? fromJson(user.roles) : [],
386!
162
    }));
387✔
163
  }
387✔
164

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

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

2✔
207
    if (!user.password) {
3!
208
      throw new IncorrectPasswordError();
×
209
    }
×
210

2✔
211
    const correct = await bcrypt.compare(password, user.password);
2✔
212
    if (!correct) {
3✔
213
      throw new IncorrectPasswordError();
1✔
214
    }
1✔
215

1✔
216
    return omit(user, 'password');
1✔
217
  }
3✔
218

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

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

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

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

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

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

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

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

×
307
        return user;
×
308
      }
×
309
    }).catch(rethrowInsertError);
×
310

×
311
    return user;
×
312
  }
×
313

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

10✔
322
    this.#logger.info({ username, email: email.toLowerCase() }, 'create user');
10✔
323

10✔
324
    const hash = await encryptPassword(password);
10✔
325

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

10✔
346
    let user;
10✔
347
    try {
10✔
348
      user = await insert.executeTakeFirstOrThrow();
10✔
349
    } catch (err) {
10✔
350
      rethrowInsertError(err);
2✔
351
    }
2✔
352

8✔
353
    const roles = ['user'];
8✔
354
    await acl.allow(user, roles);
8✔
355

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

8✔
361
    return Object.assign(user, { roles });
8✔
362
  }
10✔
363

152✔
364
  /**
152✔
365
   * @param {UserID} id
152✔
366
   * @param {string} password
152✔
367
   */
152✔
368
  async updatePassword(id, password, tx = this.#uw.db) {
152✔
NEW
369
    // TODO(@goto-bus-stop): encrypting the password while in a transaction seems silly.
×
NEW
370
    // A tagged type could be used to enforce that the user encrypts the password?
×
371
    const hash = await encryptPassword(password);
×
NEW
372
    const result = await tx.updateTable('users')
×
373
      .where('id', '=', id)
×
374
      .set({ password: hash })
×
375
      .executeTakeFirst();
×
376
    if (Number(result.numUpdatedRows) === 0) {
×
377
      throw new UserNotFoundError({ id });
×
378
    }
×
379
  }
×
380

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

4✔
389
    const user = await this.getUser(id);
4✔
390
    if (!user) throw new UserNotFoundError({ id });
4!
391

4✔
392
    this.#logger.info({ userId: user.id, update }, 'update user');
4✔
393

4✔
394
    const moderator = options.moderator;
4✔
395

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

4✔
405
    const derivedUpdates = {};
4✔
406
    if ('username' in update && update.username != null) {
4✔
407
      derivedUpdates.slug = slugify(update.username);
4✔
408
    }
4✔
409

4✔
410
    const updatesFromDatabase = await db.updateTable('users')
4✔
411
      .where('id', '=', id)
4✔
412
      .set({ ...update, ...derivedUpdates })
4✔
413
      .returning(['username', 'slug'])
4✔
414
      .executeTakeFirst()
4✔
415
      .catch(rethrowInsertError);
4✔
416
    if (!updatesFromDatabase) {
4!
417
      throw new UserNotFoundError({ id });
×
418
    }
×
419
    Object.assign(user, updatesFromDatabase);
3✔
420

3✔
421
    this.#uw.publish('user:update', {
3✔
422
      userID: user.id,
3✔
423
      moderatorID: moderator ? moderator.id : null,
4!
424
      old,
4✔
425
      new: updatesFromDatabase,
4✔
426
    });
4✔
427

4✔
428
    return user;
4✔
429
  }
4✔
430
}
152✔
431

1✔
432
/**
1✔
433
 * @param {import('../Uwave.js').default} uw
1✔
434
 */
1✔
435
async function usersPlugin(uw) {
152✔
436
  uw.users = new UsersRepository(uw);
152✔
437
}
152✔
438

1✔
439
export default usersPlugin;
1✔
440
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

© 2026 Coveralls, Inc