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

u-wave / core / 11085094286

28 Sep 2024 03:39PM UTC coverage: 79.715% (-0.4%) from 80.131%
11085094286

Pull #637

github

web-flow
Merge 11ccf3b06 into 14c162f19
Pull Request #637: Switch to a relational database, closes #549

751 of 918 branches covered (81.81%)

Branch coverage included in aggregate %.

1891 of 2530 new or added lines in 50 files covered. (74.74%)

13 existing lines in 7 files now uncovered.

9191 of 11554 relevant lines covered (79.55%)

68.11 hits per line

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

59.64
/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'))
179✔
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',
183✔
31
  /** @type {import('kysely').RawBuilder<string>} */ (sql`concat('https://sigil.u-wave.net/', ${eb.ref('users.id')})`),
183✔
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
   */
92✔
113
  async getUser(id) {
92✔
114
    const [user] = await this.getUsersByIds([id]);
178✔
115
    return user ?? null;
178!
116
  }
178✔
117

92✔
118
  /**
92✔
119
   * @param {UserID[]} ids
92✔
120
   */
92✔
121
  async getUsersByIds(ids) {
92✔
122
    const { db } = this.#uw;
178✔
123

178✔
124
    const users = await db.selectFrom('users')
178✔
125
      .where('id', 'in', ids)
178✔
126
      .select([
178✔
127
        'users.id',
178✔
128
        'users.username',
178✔
129
        'users.slug',
178✔
130
        'users.activePlaylistID',
178✔
131
        'users.pendingActivation',
178✔
132
        'users.createdAt',
178✔
133
        'users.updatedAt',
178✔
134
        (eb) => avatarColumn(eb).as('avatar'),
178✔
135
        (eb) => userRolesColumn(eb).as('roles'),
178✔
136
      ])
178✔
137
      .execute();
178✔
138

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

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

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

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

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

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

×
NEW
198
    return omit(user, 'password');
×
199
  }
×
200

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

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

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

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

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

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

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

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

×
NEW
289
        return user;
×
NEW
290
      }
×
NEW
291
    });
×
292

×
NEW
293
    return user;
×
294
  }
×
295

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

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

4✔
306
    const hash = await encryptPassword(password);
4✔
307

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

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

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

4✔
337
    return Object.assign(user, { roles });
4✔
338
  }
4✔
339

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

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

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

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

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

×
NEW
370
    const moderator = options.moderator;
×
371

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

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

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

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

×
406
    return user;
×
407
  }
×
408
}
92✔
409

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

1✔
417
export default usersPlugin;
1✔
418
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