diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f5cc866..bd7accdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,19 +35,12 @@ jobs: name: Tests strategy: matrix: - node-version: ['18.x', '20.x'] - mongo-version: ['6.0', '7.0'] - include: - - node-version: 'lts/*' - mongo-version: '5.0' + node-version: ['18.x', '20.x', '22.x'] runs-on: ubuntu-latest services: redis: image: redis:6 ports: ['6379:6379'] - mongodb: - image: mongo:${{matrix.mongo-version}} - ports: ['27017:27017'] steps: - name: Checkout sources uses: actions/checkout@v4 @@ -63,7 +56,7 @@ jobs: REDIS_URL: '127.0.0.1:6379' MONGODB_HOST: '127.0.0.1:27017' - name: Submit coverage - if: matrix.node-version == '20.x' && matrix.mongo-version == '7.0' + if: matrix.node-version == '20.x' uses: coverallsapp/github-action@v2.3.1 with: github-token: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore index 4b72ebcd..e16d5639 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ package-lock.json .nyc_output .env + +*.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/package.json b/package.json index b9e4994d..36948d8f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "ajv-formats": "^3.0.1", "avvio": "^9.0.0", "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.2.1", "body-parser": "^1.19.0", "cookie": "^1.0.1", "cookie-parser": "^1.4.4", @@ -47,15 +48,18 @@ "ioredis": "^5.0.1", "json-merge-patch": "^1.0.2", "jsonwebtoken": "^9.0.0", + "kysely": "^0.27.3", "lodash": "^4.17.15", "minimist": "^1.2.5", "mongoose": "^8.6.2", "ms": "^2.1.2", "node-fetch": "^3.3.1", "nodemailer": "^6.4.2", + "object.groupby": "^1.0.1", "passport": "^0.5.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", + "pg": "^8.10.0", "pino": "^9.0.0", "pino-http": "^10.1.0", "qs": "^6.9.1", @@ -76,6 +80,7 @@ "@eslint/js": "^9.12.0", "@tsconfig/node18": "^18.2.2", "@types/bcryptjs": "^2.4.2", + "@types/better-sqlite3": "^7.6.4", "@types/cookie": "^1.0.0", "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.10", @@ -90,9 +95,11 @@ "@types/node": "~18.18.0", "@types/node-fetch": "^2.5.8", "@types/nodemailer": "^6.4.1", + "@types/object.groupby": "^1.0.3", "@types/passport": "^1.0.6", "@types/passport-google-oauth20": "^2.0.7", "@types/passport-local": "^1.0.33", + "@types/pg": "^8.6.6", "@types/qs": "^6.9.6", "@types/random-string": "^0.2.0", "@types/ratelimiter": "^3.4.1", @@ -112,7 +119,7 @@ "mocha": "^10.0.0", "nock": "^13.2.0", "nodemon": "^3.0.1", - "pino-colada": "^2.2.2", + "pino-pretty": "^11.2.2", "recaptcha-test-keys": "^1.0.0", "sinon": "^19.0.2", "supertest": "^7.0.0", @@ -123,8 +130,8 @@ "scripts": { "lint": "eslint --cache .", "test": "npm run tests-only && npm run lint", - "tests-only": "c8 --reporter lcov --src src mocha --exit", + "tests-only": "c8 --reporter lcov --src src mocha", "types": "tsc -p tsconfig.json", - "start": "nodemon dev/u-wave-dev-server.js | pino-colada" + "start": "nodemon dev/u-wave-dev-server.js | pino-pretty" } } diff --git a/src/AuthRegistry.js b/src/AuthRegistry.js index 32acaed7..6427ffc9 100644 --- a/src/AuthRegistry.js +++ b/src/AuthRegistry.js @@ -15,7 +15,7 @@ class AuthRegistry { } /** - * @param {import('./models/index.js').User} user + * @param {import('./schema.js').User} user */ async createAuthToken(user) { const token = (await randomBytes(64)).toString('hex'); @@ -42,7 +42,7 @@ class AuthRegistry { throw err; } - return userID; + return /** @type {import('./schema.js').UserID} */ (userID); } } diff --git a/src/HttpApi.js b/src/HttpApi.js index 1912449b..d5ce4a90 100644 --- a/src/HttpApi.js +++ b/src/HttpApi.js @@ -65,7 +65,6 @@ function defaultCreatePasswordResetEmail({ token, requestUrl }) { * @prop {import('nodemailer').Transport} [mailTransport] * @prop {(options: { token: string, requestUrl: string }) => * import('nodemailer').SendMailOptions} [createPasswordResetEmail] - * * @typedef {object} HttpApiSettings - Runtime options for the HTTP API. * @prop {string[]} allowedOrigins */ diff --git a/src/SocketServer.js b/src/SocketServer.js index ad89957e..e7ef5d60 100644 --- a/src/SocketServer.js +++ b/src/SocketServer.js @@ -1,5 +1,4 @@ import { promisify } from 'node:util'; -import mongoose from 'mongoose'; import lodash from 'lodash'; import sjson from 'secure-json-parse'; import { WebSocketServer } from 'ws'; @@ -15,10 +14,9 @@ import LostConnection from './sockets/LostConnection.js'; import { serializeUser } from './utils/serialize.js'; const { debounce, isEmpty } = lodash; -const { ObjectId } = mongoose.mongo; /** - * @typedef {import('./models/index.js').User} User + * @typedef {import('./schema.js').User} User */ /** @@ -109,6 +107,7 @@ class SocketServer { /** * Handlers for commands that come in from clients. + * * @type {ClientActions} */ #clientActions; @@ -206,7 +205,7 @@ class SocketServer { logout: (user, _, connection) => { this.replace(connection, this.createGuestConnection(connection.socket)); if (!this.connection(user)) { - disconnectUser(this.#uw, user._id); + disconnectUser(this.#uw, user.id); } }, }; @@ -231,7 +230,6 @@ class SocketServer { this.broadcast('advance', { historyID: next.historyID, userID: next.userID, - itemID: next.itemID, media: next.media, playedAt: new Date(next.playedAt).getTime(), }); @@ -448,19 +446,22 @@ class SocketServer { /** * Create `LostConnection`s for every user that's known to be online, but that * is not currently connected to the socket server. + * * @private */ async initLostConnections() { - const { User } = this.#uw.models; - const userIDs = await this.#uw.redis.lrange('users', 0, -1); - const disconnectedIDs = userIDs - .filter((userID) => !this.connection(userID)) - .map((userID) => new ObjectId(userID)); - - /** @type {User[]} */ - const disconnectedUsers = await User.find({ - _id: { $in: disconnectedIDs }, - }).exec(); + const { db, redis } = this.#uw; + const userIDs = /** @type {import('./schema').UserID[]} */ (await redis.lrange('users', 0, -1)); + const disconnectedIDs = userIDs.filter((userID) => !this.connection(userID)); + + if (disconnectedIDs.length === 0) { + return; + } + + const disconnectedUsers = await db.selectFrom('users') + .where('id', 'in', disconnectedIDs) + .selectAll() + .execute(); disconnectedUsers.forEach((user) => { this.add(this.createLostConnection(user)); }); @@ -556,7 +557,7 @@ class SocketServer { connection.on('close', ({ banned }) => { if (banned) { this.#logger.info({ userId: user.id }, 'removing connection after ban'); - disconnectUser(this.#uw, user._id); + disconnectUser(this.#uw, user.id); } else if (!this.#closing) { this.#logger.info({ userId: user.id }, 'lost connection'); this.add(this.createLostConnection(user)); @@ -602,7 +603,7 @@ class SocketServer { // Only register that the user left if they didn't have another connection // still open. if (!this.connection(user)) { - disconnectUser(this.#uw, user._id); + disconnectUser(this.#uw, user.id); } }); return connection; @@ -659,7 +660,7 @@ class SocketServer { * * @param {string} channel * @param {string} rawCommand - * @return {Promise} + * @returns {Promise} * @private */ async onServerMessage(channel, rawCommand) { @@ -686,7 +687,7 @@ class SocketServer { /** * Stop the socket server. * - * @return {Promise} + * @returns {Promise} */ async destroy() { clearInterval(this.#pinger); @@ -707,7 +708,7 @@ class SocketServer { * Get the connection instance for a specific user. * * @param {User|string} user The user. - * @return {Connection|undefined} + * @returns {Connection|undefined} */ connection(user) { const userID = typeof user === 'object' ? user.id : user; diff --git a/src/Source.js b/src/Source.js index 6e912d43..e3cd8aac 100644 --- a/src/Source.js +++ b/src/Source.js @@ -1,31 +1,41 @@ import { SourceNoImportError } from './errors/index.js'; /** - * @typedef {import('./models/index.js').User} User - * @typedef {import('./models/index.js').Playlist} Playlist + * @typedef {import('./schema.js').User} User + * @typedef {import('./schema.js').Playlist} Playlist * @typedef {import('./plugins/playlists.js').PlaylistItemDesc} PlaylistItemDesc */ +/** + * @typedef {{ + * sourceType: string, + * sourceID: string, + * sourceData: import('type-fest').JsonObject | null, + * artist: string, + * title: string, + * duration: number, + * thumbnail: string, + * }} SourceMedia + */ + /** * @typedef {object} SourcePluginV1 * @prop {undefined|1} api - * @prop {(ids: string[]) => Promise} get - * @prop {(query: string, page: unknown, ...args: unknown[]) => Promise} search + * @prop {(ids: string[]) => Promise} get + * @prop {(query: string, page: unknown, ...args: unknown[]) => Promise} search * @prop {(context: ImportContext, ...args: unknown[]) => Promise} [import] - * * @typedef {object} SourcePluginV2 * @prop {2} api - * @prop {(context: SourceContext, ids: string[]) => Promise} get + * @prop {(context: SourceContext, ids: string[]) => Promise} get * @prop {( * context: SourceContext, * query: string, * page: unknown, * ...args: unknown[] - * ) => Promise} search + * ) => Promise} search * @prop {(context: ImportContext, ...args: unknown[]) => Promise} [import] * @prop {(context: SourceContext, entry: PlaylistItemDesc) => * Promise} [play] - * * @typedef {SourcePluginV1 | SourcePluginV2} SourcePlugin */ @@ -61,7 +71,7 @@ class ImportContext extends SourceContext { * @returns {Promise} Playlist model. */ async createPlaylist(name, itemOrItems) { - const playlist = await this.uw.playlists.createPlaylist(this.user, { name }); + const { playlist } = await this.uw.playlists.createPlaylist(this.user, { name }); const rawItems = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; const items = this.source.addSourceType(rawItems); @@ -101,8 +111,9 @@ class Source { * Media items can provide their own sourceType, too, so media sources can * aggregate items from different source types. * - * @param {Omit[]} items - * @returns {PlaylistItemDesc[]} + * @template T + * @param {T[]} items + * @returns {(T & { sourceType: string })[]} */ addSourceType(items) { return items.map((item) => ({ @@ -116,7 +127,7 @@ class Source { * * @param {User} user * @param {string} id - * @returns {Promise} + * @returns {Promise} */ getOne(user, id) { return this.get(user, [id]) @@ -128,7 +139,7 @@ class Source { * * @param {User} user * @param {string[]} ids - * @returns {Promise} + * @returns {Promise} */ async get(user, ids) { let items; @@ -150,7 +161,7 @@ class Source { * @param {string} query * @param {TPagination} [page] * @param {unknown[]} args - * @returns {Promise} + * @returns {Promise} */ async search(user, query, page, ...args) { let results; diff --git a/src/Uwave.js b/src/Uwave.js index 60be7f35..4174806b 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -1,14 +1,13 @@ import EventEmitter from 'node:events'; import { promisify } from 'node:util'; -import mongoose from 'mongoose'; import Redis from 'ioredis'; import avvio from 'avvio'; import { pino } from 'pino'; +import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'; import httpApi, { errorHandling } from './HttpApi.js'; import SocketServer from './SocketServer.js'; import { Source } from './Source.js'; import { i18n } from './locale.js'; -import models from './models/index.js'; import configStore from './plugins/configStore.js'; import booth from './plugins/booth.js'; import chat from './plugins/chat.js'; @@ -21,10 +20,22 @@ import acl from './plugins/acl.js'; import waitlist from './plugins/waitlist.js'; import passport from './plugins/passport.js'; import migrations from './plugins/migrations.js'; +import { SqliteDateColumnsPlugin, connect as connectSqlite } from './utils/sqlite.js'; const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/uwave'; const DEFAULT_REDIS_URL = 'redis://localhost:6379'; +class UwCamelCasePlugin extends CamelCasePlugin { + /** + * @param {string} str + * @override + * @protected + */ + camelCase(str) { + return super.camelCase(str).replace(/Id$/, 'ID'); + } +} + /** * @typedef {import('./Source.js').SourcePlugin} SourcePlugin */ @@ -37,6 +48,7 @@ const DEFAULT_REDIS_URL = 'redis://localhost:6379'; * >} RedisOptions * @typedef {{ * port?: number, + * sqlite?: string, * mongo?: string, * redis?: string | RedisOptions, * logger?: import('pino').LoggerOptions, @@ -55,10 +67,6 @@ class UwaveServer extends EventEmitter { // @ts-expect-error TS2564 Definitely assigned in a plugin express; - /** @type {import('./models/index.js').Models} */ - // @ts-expect-error TS2564 Definitely assigned in a plugin - models; - /** @type {import('./plugins/acl.js').Acl} */ // @ts-expect-error TS2564 Definitely assigned in a plugin acl; @@ -123,9 +131,6 @@ class UwaveServer extends EventEmitter { */ #sources = new Map(); - /** @type {import('pino').Logger} */ - #mongoLogger; - /** * @param {Options} options */ @@ -146,7 +151,22 @@ class UwaveServer extends EventEmitter { ...options, }; - this.mongo = mongoose.createConnection(this.options.mongo); + /** @type {Kysely} */ + this.db = new Kysely({ + dialect: new SqliteDialect({ + database: () => connectSqlite(options.sqlite ?? 'uwave_local.sqlite'), + }), + // dialect: new PostgresDialect({ + // pool: new pg.Pool({ + // user: 'uwave', + // database: 'uwave_test', + // }), + // }), + plugins: [ + new UwCamelCasePlugin(), + new SqliteDateColumnsPlugin(['createdAt', 'updatedAt', 'expiresAt', 'playedAt', 'lastSeenAt']), + ], + }); if (typeof options.redis === 'string') { this.redis = new Redis(options.redis, { lazyConnect: true }); @@ -154,23 +174,12 @@ class UwaveServer extends EventEmitter { this.redis = new Redis({ ...options.redis, lazyConnect: true }); } - this.#mongoLogger = this.logger.child({ ns: 'uwave:mongo' }); - this.configureRedis(); - this.configureMongoose(); boot.onClose(() => Promise.all([ this.redis.quit(), - this.mongo.close(), ])); - // Wait for the connections to be set up. - boot.use(async () => { - this.#mongoLogger.debug('waiting for mongodb...'); - await this.mongo.asPromise(); - }); - - boot.use(models); boot.use(migrations); boot.use(configStore); @@ -218,7 +227,6 @@ class UwaveServer extends EventEmitter { * * @typedef {((uw: UwaveServer, opts: object) => SourcePlugin)} SourcePluginFactory * @typedef {SourcePlugin | SourcePluginFactory} ToSourcePlugin - * * @param {string | Omit | { default: ToSourcePlugin }} sourcePlugin * Source name or definition. * When a string: Source type name. @@ -276,31 +284,6 @@ class UwaveServer extends EventEmitter { }); } - /** - * @private - */ - configureMongoose() { - this.mongo.on('error', (error) => { - this.#mongoLogger.error(error); - this.emit('mongoError', error); - }); - - this.mongo.on('reconnected', () => { - this.#mongoLogger.info('reconnected'); - this.emit('mongoReconnect'); - }); - - this.mongo.on('disconnected', () => { - this.#mongoLogger.info('disconnected'); - this.emit('mongoDisconnect'); - }); - - this.mongo.on('connected', () => { - this.#mongoLogger.info('connected'); - this.emit('mongoConnect'); - }); - } - /** * Publish an event to the üWave channel. * @@ -309,10 +292,7 @@ class UwaveServer extends EventEmitter { * @param {import('./redisMessages.js').ServerActionParameters[CommandName]} data */ publish(command, data) { - this.redis.publish('uwave', JSON.stringify({ - command, data, - })); - return this; + return this.redis.publish('uwave', JSON.stringify({ command, data })); } async listen() { diff --git a/src/auth/JWTStrategy.js b/src/auth/JWTStrategy.js index 7d80a215..2d451d04 100644 --- a/src/auth/JWTStrategy.js +++ b/src/auth/JWTStrategy.js @@ -3,7 +3,10 @@ import { Strategy } from 'passport'; import jwt from 'jsonwebtoken'; import { BannedError } from '../errors/index.js'; -/** @typedef {import('../models/index.js').User} User */ +/** + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').User} User + */ /** * @param {Record} cookies @@ -34,7 +37,7 @@ function getHeaderToken(headers) { /** * @param {unknown} obj - * @returns {obj is { id: string }} + * @returns {obj is { id: UserID }} */ function isUserIDToken(obj) { return typeof obj === 'object' @@ -43,7 +46,7 @@ function isUserIDToken(obj) { && typeof obj.id === 'string'; } -/** @typedef {(claim: { id: string }) => Promise} GetUserFn */ +/** @typedef {(claim: { id: UserID }) => Promise} GetUserFn */ class JWTStrategy extends Strategy { /** diff --git a/src/config/defaultRoles.js b/src/config/defaultRoles.js index 5e3800db..21317714 100644 --- a/src/config/defaultRoles.js +++ b/src/config/defaultRoles.js @@ -1,44 +1,49 @@ -export default { - admin: [ - '*', - ], - manager: [ - 'moderator', - 'waitlist.clear', - 'chat.mention.everyone', - 'motd.set', - ], - moderator: [ - 'user', - 'waitlist.add', - 'waitlist.remove', - 'waitlist.move', - 'waitlist.lock', - 'waitlist.join.locked', - 'booth.skip.other', - 'chat.delete', - 'chat.mute', - 'chat.unmute', - 'chat.mention.djs', - 'users.list', - 'users.bans.list', - 'users.bans.add', - 'users.bans.remove', - ], - special: [ - 'user', - ], - user: [ - 'waitlist.join', - 'waitlist.leave', - 'booth.skip.self', - 'booth.vote', - 'chat.send', - 'chat.mention.staff', - ], - guest: [], +const admin = ['*']; - // Individual roles, only assigned to superusers via the '*' role by default. - 'acl.create': [], - 'acl.delete': [], -}; +/** @type {string[]} */ +const guest = []; + +const user = [ + 'waitlist.join', + 'waitlist.leave', + 'booth.skip.self', + 'booth.vote', + 'chat.send', + 'chat.mention.staff', +]; + +const moderator = [ + ...user, + 'waitlist.add', + 'waitlist.remove', + 'waitlist.move', + 'waitlist.lock', + 'waitlist.join.locked', + 'booth.skip.other', + 'chat.delete', + 'chat.mute', + 'chat.unmute', + 'chat.mention.djs', + 'users.list', + 'users.bans.list', + 'users.bans.add', + 'users.bans.remove', +]; + +const manager = [ + ...moderator, + 'waitlist.clear', + 'chat.mention.everyone', + 'motd.set', +]; + +/** @typedef {Record} PermissionMap */ + +export default /** @type {PermissionMap} */ (/** @type {Record} */ ({ + admin, + manager, + moderator, + special: user, + user, + guest, +})); diff --git a/src/controllers/acl.js b/src/controllers/acl.js index 4050cff9..87c4c601 100644 --- a/src/controllers/acl.js +++ b/src/controllers/acl.js @@ -16,7 +16,6 @@ async function list(req) { /** * @typedef {object} CreateRoleParams * @prop {string} name - * * @typedef {object} CreateRoleBody * @prop {string[]} permissions */ @@ -29,7 +28,10 @@ async function createRole(req, res) { const { permissions } = req.body; const { acl } = req.uwave; - const role = await acl.createRole(name, permissions); + const role = await acl.createRole( + name, + /** @type {import('../schema.js').Permission[]} */ (permissions), + ); res.status(201); return toItemResponse(role, { diff --git a/src/controllers/authenticate.js b/src/controllers/authenticate.js index 53dece86..478f2c9b 100644 --- a/src/controllers/authenticate.js +++ b/src/controllers/authenticate.js @@ -5,19 +5,24 @@ import nodeFetch from 'node-fetch'; import ms from 'ms'; import htmlescape from 'htmlescape'; import httpErrors from 'http-errors'; +import nodemailer from 'nodemailer'; import { BannedError, ReCaptchaError, InvalidResetTokenError, UserNotFoundError, } from '../errors/index.js'; -import sendEmail from '../email.js'; import beautifyDuplicateKeyError from '../utils/beautifyDuplicateKeyError.js'; import toItemResponse from '../utils/toItemResponse.js'; import toListResponse from '../utils/toListResponse.js'; +import { serializeUser } from '../utils/serialize.js'; const { BadRequest } = httpErrors; +/** + * @typedef {import('../schema').UserID} UserID + */ + /** * @typedef {object} AuthenticateOptions * @prop {string|Buffer} secret @@ -28,7 +33,6 @@ const { BadRequest } = httpErrors; * import('nodemailer').SendMailOptions} createPasswordResetEmail * @prop {boolean} [cookieSecure] * @prop {string} [cookiePath] - * * @typedef {object} WithAuthOptions * @prop {AuthenticateOptions} authOptions */ @@ -44,7 +48,7 @@ function seconds(str) { * @type {import('../types.js').Controller} */ async function getCurrentUser(req) { - return toItemResponse(req.user ?? null, { + return toItemResponse(req.user != null ? serializeUser(req.user) : null, { url: req.fullUrl, }); } @@ -66,7 +70,7 @@ async function getAuthStrategies(req) { /** * @param {import('express').Response} res * @param {import('../HttpApi.js').HttpApi} api - * @param {import('../models/index.js').User} user + * @param {import('../schema.js').User} user * @param {AuthenticateOptions & { session: 'cookie' | 'token' }} options */ async function refreshSession(res, api, user, options) { @@ -98,7 +102,6 @@ async function refreshSession(res, api, user, options) { * * @typedef {object} LoginQuery * @prop {'cookie'|'token'} [session] - * * @param {import('../types.js').AuthenticatedRequest<{}, LoginQuery, {}> & WithAuthOptions} req * @param {import('express').Response} res */ @@ -129,21 +132,17 @@ async function login(req, res) { /** * @param {import('../Uwave.js').default} uw - * @param {import('../models/index.js').User} user + * @param {import('../schema.js').User} user * @param {string} service */ async function getSocialAvatar(uw, user, service) { - const { Authentication } = uw.models; + const auth = await uw.db.selectFrom('authServices') + .where('userID', '=', user.id) + .where('service', '=', service) + .select(['serviceAvatar']) + .executeTakeFirst(); - /** @type {import('../models/index.js').Authentication|null} */ - const auth = await Authentication.findOne({ - user: user._id, - type: service, - }); - if (auth && auth.avatar) { - return auth.avatar; - } - return null; + return auth?.serviceAvatar ?? null; } /** @@ -213,7 +212,6 @@ async function socialLoginCallback(service, req, res) { /** * @typedef {object} SocialLoginFinishQuery * @prop {'cookie'|'token'} [session] - * * @typedef {object} SocialLoginFinishBody * @prop {string} username * @prop {string} avatar @@ -229,7 +227,7 @@ async function socialLoginFinish(service, req, res) { const options = req.authOptions; const { pendingUser: user } = req; const sessionType = req.query.session === 'cookie' ? 'cookie' : 'token'; - const { bans } = req.uwave; + const { db, bans } = req.uwave; if (!user) { // Should never happen so not putting much effort into @@ -252,10 +250,17 @@ async function socialLoginFinish(service, req, res) { avatarUrl = `https://sigil.u-wave.net/${user.id}`; } - user.username = username; - user.avatar = avatarUrl; - user.pendingActivation = undefined; - await user.save(); + const updates = await db.updateTable('users') + .where('id', '=', user.id) + .set({ + username, + avatar: avatarUrl, + pendingActivation: false, + }) + .returning(['username', 'avatar', 'pendingActivation']) + .executeTakeFirst(); + + Object.assign(user, updates); const { token, socketToken } = await refreshSession(res, req.uwaveHttp, user, { ...options, @@ -348,7 +353,7 @@ async function register(req) { password, }); - return toItemResponse(user); + return toItemResponse(serializeUser(user)); } catch (error) { throw beautifyDuplicateKeyError(error); } @@ -363,40 +368,45 @@ async function register(req) { * @param {import('../types.js').Request<{}, {}, RequestPasswordResetBody> & WithAuthOptions} req */ async function reset(req) { - const uw = req.uwave; - const { Authentication } = uw.models; + const { db, redis } = req.uwave; const { email } = req.body; const { mailTransport, createPasswordResetEmail } = req.authOptions; - const auth = await Authentication.findOne({ - email: email.toLowerCase(), - }); - if (!auth) { + const user = await db.selectFrom('users') + .where('email', '=', email) + .select(['id']) + .executeTakeFirst(); + if (!user) { throw new UserNotFoundError({ email }); } const token = randomString({ length: 35, special: false }); - await uw.redis.set(`reset:${token}`, auth.user.toString()); - await uw.redis.expire(`reset:${token}`, 24 * 60 * 60); + await redis.set(`reset:${token}`, user.id); + await redis.expire(`reset:${token}`, 24 * 60 * 60); - const message = await createPasswordResetEmail({ + const message = createPasswordResetEmail({ token, requestUrl: req.fullUrl, }); - await sendEmail(email, { - mailTransport, - email: message, + const transporter = nodemailer.createTransport(mailTransport ?? { + host: 'localhost', + port: 25, + debug: true, + tls: { + rejectUnauthorized: false, + }, }); + await transporter.sendMail({ to: email, ...message }); + return toItemResponse({}); } /** * @typedef {object} ChangePasswordParams * @prop {string} reset - * * @typedef {object} ChangePasswordBody * @prop {string} password */ @@ -409,14 +419,14 @@ async function changePassword(req) { const { reset: resetToken } = req.params; const { password } = req.body; - const userId = await redis.get(`reset:${resetToken}`); - if (!userId) { + const userID = /** @type {UserID} */ (await redis.get(`reset:${resetToken}`)); + if (!userID) { throw new InvalidResetTokenError(); } - const user = await users.getUser(userId); + const user = await users.getUser(userID); if (!user) { - throw new UserNotFoundError({ id: userId }); + throw new UserNotFoundError({ id: userID }); } await users.updatePassword(user.id, password); diff --git a/src/controllers/bans.js b/src/controllers/bans.js index 5b1063af..cd5bd000 100644 --- a/src/controllers/bans.js +++ b/src/controllers/bans.js @@ -3,6 +3,8 @@ import getOffsetPagination from '../utils/getOffsetPagination.js'; import toItemResponse from '../utils/toItemResponse.js'; import toPaginatedResponse from '../utils/toPaginatedResponse.js'; +/** @typedef {import('../schema').UserID} UserID */ + /** * @typedef {object} GetBansQuery * @prop {string} filter @@ -29,7 +31,7 @@ async function getBans(req) { /** * @typedef {object} AddBanBody * @prop {number} [duration] - * @prop {string} userID + * @prop {UserID} userID * @prop {boolean} [permanent] * @prop {string} [reason] */ @@ -66,7 +68,7 @@ async function addBan(req) { /** * @typedef {object} RemoveBanParams - * @prop {string} userID + * @prop {UserID} userID */ /** diff --git a/src/controllers/booth.js b/src/controllers/booth.js index b5832229..94c7d3e7 100644 --- a/src/controllers/booth.js +++ b/src/controllers/booth.js @@ -1,5 +1,3 @@ -import assert from 'node:assert'; -import mongoose from 'mongoose'; import { HTTPError, PermissionError, @@ -12,8 +10,17 @@ import getOffsetPagination from '../utils/getOffsetPagination.js'; import toItemResponse from '../utils/toItemResponse.js'; import toListResponse from '../utils/toListResponse.js'; import toPaginatedResponse from '../utils/toPaginatedResponse.js'; +import { Permissions } from '../plugins/acl.js'; -const { ObjectId } = mongoose.Types; +/** + * @typedef {import('../schema').UserID} UserID + * @typedef {import('../schema').MediaID} MediaID + * @typedef {import('../schema').PlaylistID} PlaylistID + * @typedef {import('../schema').HistoryEntryID} HistoryEntryID + */ + +const REDIS_HISTORY_ID = 'booth:historyID'; +const REDIS_CURRENT_DJ_ID = 'booth:currentDJ'; /** * @param {import('../Uwave.js').default} uw @@ -21,25 +28,25 @@ const { ObjectId } = mongoose.Types; async function getBoothData(uw) { const { booth } = uw; - const historyEntry = await booth.getCurrentEntry(); - - if (!historyEntry || !historyEntry.user) { + const state = await booth.getCurrentEntry(); + if (state == null) { return null; } - await historyEntry.populate('media.media'); // @ts-expect-error TS2322: We just populated historyEntry.media.media - const media = booth.getMediaForPlayback(historyEntry); - - const stats = await booth.getCurrentVoteStats(); + const media = booth.getMediaForPlayback(state); return { - historyID: historyEntry.id, - playlistID: `${historyEntry.playlist}`, - playedAt: historyEntry.playedAt.getTime(), - userID: `${historyEntry.user}`, + historyID: state.historyEntry.id, + // playlistID: state.playlist.id, + playedAt: state.historyEntry.createdAt.getTime(), + userID: state.user.id, media, - stats, + stats: { + upvotes: state.upvotes, + downvotes: state.downvotes, + favorites: state.favorites, + }, }; } @@ -62,16 +69,22 @@ async function getBooth(req) { /** * @param {import('../Uwave.js').default} uw - * @returns {Promise} */ function getCurrentDJ(uw) { - return uw.redis.get('booth:currentDJ'); + return /** @type {Promise} */ (uw.redis.get(REDIS_CURRENT_DJ_ID)); } /** * @param {import('../Uwave.js').default} uw - * @param {string|null} moderatorID - `null` if a user is skipping their own turn. - * @param {string} userID + */ +function getCurrentHistoryID(uw) { + return /** @type {Promise} */ (uw.redis.get(REDIS_HISTORY_ID)); +} + +/** + * @param {import('../Uwave.js').default} uw + * @param {UserID|null} moderatorID - `null` if a user is skipping their own turn. + * @param {UserID} userID * @param {string|null} reason * @param {{ remove?: boolean }} [opts] */ @@ -89,12 +102,11 @@ async function doSkip(uw, moderatorID, userID, reason, opts = {}) { /** * @typedef {object} SkipUserAndReason - * @prop {string} userID + * @prop {UserID} userID * @prop {string} reason - * * @typedef {{ * remove?: boolean, - * userID?: string, + * userID?: UserID, * reason?: string, * } & (SkipUserAndReason | {})} SkipBoothBody */ @@ -121,8 +133,8 @@ async function skipBooth(req) { return toItemResponse({}); } - if (!await acl.isAllowed(user, 'booth.skip.other')) { - throw new PermissionError({ requiredRole: 'booth.skip.other' }); + if (!await acl.isAllowed(user, Permissions.SkipOther)) { + throw new PermissionError({ requiredRole: Permissions.SkipOther }); } // @ts-expect-error TS2345 pretending like `userID` is definitely defined here @@ -132,7 +144,7 @@ async function skipBooth(req) { return toItemResponse({}); } -/** @typedef {{ userID: string, autoLeave: boolean }} LeaveBoothBody */ +/** @typedef {{ userID: UserID, autoLeave: boolean }} LeaveBoothBody */ /** * @type {import('../types.js').AuthenticatedController<{}, {}, LeaveBoothBody>} @@ -149,8 +161,8 @@ async function leaveBooth(req) { return toItemResponse({ autoLeave: value }); } - if (!await acl.isAllowed(self, 'booth.skip.other')) { - throw new PermissionError({ requiredRole: 'booth.skip.other' }); + if (!await acl.isAllowed(self, Permissions.SkipOther)) { + throw new PermissionError({ requiredRole: Permissions.SkipOther }); } const user = await users.getUser(userID); @@ -164,7 +176,7 @@ async function leaveBooth(req) { /** * @typedef {object} ReplaceBoothBody - * @prop {string} userID + * @prop {UserID} userID */ /** @@ -198,54 +210,58 @@ async function replaceBooth(req) { /** * @param {import('../Uwave.js').default} uw - * @param {string} userID + * @param {HistoryEntryID} historyEntryID + * @param {UserID} userID * @param {1|-1} direction */ -async function addVote(uw, userID, direction) { - const results = await uw.redis.multi() - .srem('booth:upvotes', userID) - .srem('booth:downvotes', userID) - .sadd(direction > 0 ? 'booth:upvotes' : 'booth:downvotes', userID) - .exec(); - assert(results); - - const replacedUpvote = results[0][1] !== 0; - const replacedDownvote = results[1][1] !== 0; - - // Replaced an upvote by an upvote or a downvote by a downvote: the vote didn't change. - // We don't need to broadcast the non-change to everyone. - if ((replacedUpvote && direction > 0) || (replacedDownvote && direction < 0)) { - return; +async function addVote(uw, historyEntryID, userID, direction) { + const result = await uw.db.insertInto('feedback') + .values({ + historyEntryID, + userID, + vote: direction, + }) + // We should only broadcast the vote if it changed, + // so we make sure not to update the vote if the value is the same. + .onConflict((oc) => oc + .columns(['historyEntryID', 'userID']) + .doUpdateSet({ vote: direction }) + .where('vote', '!=', direction)) + .executeTakeFirst(); + + if (result != null && result.numInsertedOrUpdatedRows != null + && result.numInsertedOrUpdatedRows > 0n) { + uw.publish('booth:vote', { + userID, direction, + }); } - - uw.publish('booth:vote', { - userID, direction, - }); } /** * Old way of voting: over the WebSocket * * @param {import('../Uwave.js').default} uw - * @param {string} userID + * @param {UserID} userID * @param {1|-1} direction */ async function socketVote(uw, userID, direction) { const currentDJ = await getCurrentDJ(uw); - if (currentDJ !== null && currentDJ !== userID) { - const historyID = await uw.redis.get('booth:historyID'); - if (historyID === null) return; + if (currentDJ != null && currentDJ !== userID) { + const historyEntryID = await getCurrentHistoryID(uw); + if (historyEntryID == null) { + return; + } if (direction > 0) { - await addVote(uw, userID, 1); + await addVote(uw, historyEntryID, userID, 1); } else { - await addVote(uw, userID, -1); + await addVote(uw, historyEntryID, userID, -1); } } } /** * @typedef {object} GetVoteParams - * @prop {string} historyID + * @prop {HistoryEntryID} historyID */ /** @@ -255,36 +271,27 @@ async function getVote(req) { const { uwave: uw, user } = req; const { historyID } = req.params; - const [currentDJ, currentHistoryID] = await Promise.all([ - getCurrentDJ(uw), - uw.redis.get('booth:historyID'), - ]); - if (currentDJ === null || currentHistoryID === null) { + const currentHistoryID = await getCurrentHistoryID(uw); + if (currentHistoryID == null) { throw new HTTPError(412, 'Nobody is playing'); } if (historyID && historyID !== currentHistoryID) { throw new HTTPError(412, 'Cannot get vote for media that is not currently playing'); } - const [upvoted, downvoted] = await Promise.all([ - uw.redis.sismember('booth:upvotes', user.id), - uw.redis.sismember('booth:downvotes', user.id), - ]); - - let direction = 0; - if (upvoted) { - direction = 1; - } else if (downvoted) { - direction = -1; - } + const feedback = await uw.db.selectFrom('feedback') + .where('historyEntryID', '=', historyID) + .where('userID', '=', user.id) + .select('vote') + .executeTakeFirst(); + const direction = feedback?.vote ?? 0; return toItemResponse({ direction }); } /** * @typedef {object} VoteParams - * @prop {string} historyID - * + * @prop {HistoryEntryID} historyID * @typedef {object} VoteBody * @prop {1|-1} direction */ @@ -299,9 +306,9 @@ async function vote(req) { const [currentDJ, currentHistoryID] = await Promise.all([ getCurrentDJ(uw), - uw.redis.get('booth:historyID'), + getCurrentHistoryID(uw), ]); - if (currentDJ === null || currentHistoryID === null) { + if (currentDJ == null || currentHistoryID == null) { throw new HTTPError(412, 'Nobody is playing'); } if (currentDJ === user.id) { @@ -312,9 +319,9 @@ async function vote(req) { } if (direction > 0) { - await addVote(uw, user.id, 1); + await addVote(uw, historyID, user.id, 1); } else { - await addVote(uw, user.id, -1); + await addVote(uw, historyID, user.id, -1); } return toItemResponse({}); @@ -322,8 +329,8 @@ async function vote(req) { /** * @typedef {object} FavoriteBody - * @prop {string} playlistID - * @prop {string} historyID + * @prop {PlaylistID} playlistID + * @prop {HistoryEntryID} historyID */ /** @@ -332,41 +339,49 @@ async function vote(req) { async function favorite(req) { const { user } = req; const { playlistID, historyID } = req.body; + const { db, history, playlists } = req.uwave; const uw = req.uwave; - const { PlaylistItem, HistoryEntry } = uw.models; - const historyEntry = await HistoryEntry.findById(historyID); + const historyEntry = await history.getEntry(historyID); if (!historyEntry) { throw new HistoryEntryNotFoundError({ id: historyID }); } - if (`${historyEntry.user}` === user.id) { + if (historyEntry.user._id === user.id) { throw new CannotSelfFavoriteError(); } - const playlist = await uw.playlists.getUserPlaylist(user, new ObjectId(playlistID)); + const playlist = await playlists.getUserPlaylist(user, playlistID); if (!playlist) { throw new PlaylistNotFoundError({ id: playlistID }); } - // `.media` has the same shape as `.item`, but is guaranteed to exist and have - // the same properties as when the playlist item was actually played. - const itemProps = historyEntry.media.toJSON(); - const playlistItem = await PlaylistItem.create(itemProps); + const result = await playlists.addPlaylistItems( + playlist, + [{ + sourceType: historyEntry.media.media.sourceType, + sourceID: historyEntry.media.media.sourceID, + artist: historyEntry.media.artist, + title: historyEntry.media.title, + start: historyEntry.media.start, + end: historyEntry.media.end, + }], + { at: 'end' }, + ); + + await db.insertInto('feedback') + .values({ userID: user.id, historyEntryID: historyID, favorite: 1 }) + .onConflict((oc) => oc.columns(['userID', 'historyEntryID']).doUpdateSet({ favorite: 1 })) + .execute(); - playlist.media.push(playlistItem.id); - - await uw.redis.sadd('booth:favorites', user.id); uw.publish('booth:favorite', { userID: user.id, playlistID, }); - await playlist.save(); - - return toListResponse([playlistItem], { + return toListResponse(result.added, { meta: { - playlistSize: playlist.media.length, + playlistSize: result.playlistSize, }, included: { media: ['media'], @@ -376,7 +391,7 @@ async function favorite(req) { /** * @typedef {object} GetRoomHistoryQuery - * @prop {import('../types.js').PaginationQuery & { media?: string }} [filter] + * @prop {import('../types.js').PaginationQuery & { media?: MediaID }} [filter] */ /** * @type {import('../types.js').Controller} @@ -393,7 +408,9 @@ async function getHistory(req) { filter['media.media'] = req.query.filter.media; } - const roomHistory = await history.getHistory(filter, pagination); + // TODO: Support filter? + + const roomHistory = await history.getRoomHistory(pagination); return toPaginatedResponse(roomHistory, { baseUrl: req.fullUrl, diff --git a/src/controllers/chat.js b/src/controllers/chat.js index f07e51eb..bae77180 100644 --- a/src/controllers/chat.js +++ b/src/controllers/chat.js @@ -1,10 +1,13 @@ import { UserNotFoundError, CannotSelfMuteError } from '../errors/index.js'; import toItemResponse from '../utils/toItemResponse.js'; +/** + * @typedef {import('../schema').UserID} UserID + */ + /** * @typedef {object} MuteUserParams - * @prop {string} id - * + * @prop {UserID} id * @typedef {object} MuteUserBody * @prop {number} time */ @@ -32,7 +35,7 @@ async function muteUser(req) { /** * @typedef {object} UnmuteUserParams - * @prop {string} id + * @prop {UserID} id */ /** @@ -69,7 +72,7 @@ async function deleteAll(req) { /** * @typedef {object} DeleteByUserParams - * @prop {string} id + * @prop {UserID} id */ /** diff --git a/src/controllers/now.js b/src/controllers/now.js index 94a65fd6..8bb290b3 100644 --- a/src/controllers/now.js +++ b/src/controllers/now.js @@ -1,19 +1,20 @@ -import mongoose from 'mongoose'; import { getBoothData } from './booth.js'; import { serializePlaylist, serializeUser } from '../utils/serialize.js'; +import { legacyPlaylistItem } from './playlists.js'; -const { ObjectId } = mongoose.mongo; +/** + * @typedef {import('../schema.js').UserID} UserID + */ /** * @param {import('../Uwave.js').default} uw - * @param {Promise} activePlaylist + * @param {import('../schema.js').Playlist & { size: number }} playlist */ -async function getFirstItem(uw, activePlaylist) { +async function getFirstItem(uw, playlist) { try { - const playlist = await activePlaylist; - if (playlist && playlist.size > 0) { - const item = await uw.playlists.getPlaylistItem(playlist, playlist.media[0]); - return item; + if (playlist.size > 0) { + const { playlistItem, media } = await uw.playlists.getPlaylistItemAt(playlist, 0); + return legacyPlaylistItem(playlistItem, media); } } catch { // Nothing @@ -34,21 +35,12 @@ function toInt(str) { * @param {import('../Uwave.js').default} uw */ async function getOnlineUsers(uw) { - const { User } = uw.models; - - const userIDs = await uw.redis.lrange('users', 0, -1); - /** @type {Omit[]} */ - const users = await User.find({ - _id: { - $in: userIDs.map((id) => new ObjectId(id)), - }, - }).select({ - activePlaylist: 0, - exiled: 0, - level: 0, - __v: 0, - }).lean(); + const userIDs = /** @type {UserID[]} */ (await uw.redis.lrange('users', 0, -1)); + if (userIDs.length === 0) { + return []; + } + const users = await uw.users.getUsersByIds(userIDs); return users.map(serializeUser); } @@ -77,31 +69,27 @@ async function getState(req) { const waitlist = uw.waitlist.getUserIDs(); const waitlistLocked = uw.waitlist.isLocked(); const autoLeave = user != null ? uw.booth.getRemoveAfterCurrentPlay(user) : false; - let activePlaylist = user?.activePlaylist - ? uw.playlists.getUserPlaylist(user, user.activePlaylist) - : null; + let activePlaylist = user?.activePlaylistID + ? uw.playlists.getUserPlaylist(user, user.activePlaylistID).catch((error) => { + // If the playlist was not found, our database is inconsistent. A deleted or nonexistent + // playlist should never be listed as the active playlist. Most likely this is not the + // user's fault, so we should not error out on `/api/now`. Instead, pretend they don't have + // an active playlist at all. Clients can then let them select a new playlist to activate. + if (error.code === 'NOT_FOUND' || error.code === 'playlist-not-found') { + req.log.warn('The active playlist does not exist', { error }); + return null; + } + throw error; + }) + : Promise.resolve(null); const playlists = user ? uw.playlists.getUserPlaylists(user) : null; - const firstActivePlaylistItem = activePlaylist ? getFirstItem(uw, activePlaylist) : null; + const firstActivePlaylistItem = activePlaylist.then((playlist) => ( + playlist != null ? getFirstItem(uw, playlist) : null + )); const socketToken = user ? authRegistry.createAuthToken(user) : null; const authStrategies = passport.strategies(); const time = Date.now(); - if (activePlaylist != null) { - activePlaylist = activePlaylist - .then((playlist) => playlist?.id) - .catch((error) => { - // If the playlist was not found, our database is inconsistent. A deleted or nonexistent - // playlist should never be listed as the active playlist. Most likely this is not the - // user's fault, so we should not error out on `/api/now`. Instead, pretend they don't have - // an active playlist at all. Clients can then let them select a new playlist to activate. - if (error.code === 'NOT_FOUND' || error.code === 'playlist-not-found') { - req.log.warn('The active playlist does not exist', { error }); - return null; - } - throw error; - }); - } - const stateShape = { motd, user: user ? serializeUser(user) : null, @@ -112,7 +100,7 @@ async function getState(req) { waitlist, waitlistLocked, autoLeave, - activePlaylist, + activePlaylist: activePlaylist.then((playlist) => playlist?.id ?? null), firstActivePlaylistItem, playlists, socketToken, @@ -137,6 +125,13 @@ async function getState(req) { state.playlists = state.playlists.map(serializePlaylist); } + for (const permission of Object.values(state.roles).flat()) { + // Web client expects all permissions to be roles too. + // This isn't how it works since #637. + // Clients can still distinguish between roles and permissions using `.includes('.')` + state.roles[permission] ??= []; + } + return state; } diff --git a/src/controllers/playlists.js b/src/controllers/playlists.js index 3a165e0a..f7c5a1df 100644 --- a/src/controllers/playlists.js +++ b/src/controllers/playlists.js @@ -1,16 +1,52 @@ -import mongoose from 'mongoose'; -import { HTTPError, PlaylistNotFoundError, PlaylistItemNotFoundError } from '../errors/index.js'; -import { serializePlaylist } from '../utils/serialize.js'; +import { HTTPError, PlaylistNotFoundError } from '../errors/index.js'; +import { serializePlaylist, serializePlaylistItem } from '../utils/serialize.js'; import getOffsetPagination from '../utils/getOffsetPagination.js'; import toItemResponse from '../utils/toItemResponse.js'; import toListResponse from '../utils/toListResponse.js'; import toPaginatedResponse from '../utils/toPaginatedResponse.js'; -const { ObjectId } = mongoose.mongo; +/** + * @typedef {import('../schema').PlaylistID} PlaylistID + * @typedef {import('../schema').PlaylistItemID} PlaylistItemID + * @typedef {import('../schema').MediaID} MediaID + */ + +/** + * TODO move to a serializer? + * + * @param {Pick< + * import('../schema').PlaylistItem, + * 'id' | 'artist' | 'title' | 'start' | 'end' | 'createdAt' + * >} playlistItem + * @param {Pick< + * import('../schema').Media, + * 'id' | 'sourceType' | 'sourceID' | 'sourceData' | 'artist' | 'title' | 'duration' | 'thumbnail' + * >} media + */ +export function legacyPlaylistItem(playlistItem, media) { + return { + _id: playlistItem.id, + artist: playlistItem.artist, + title: playlistItem.title, + start: playlistItem.start, + end: playlistItem.end, + media: { + _id: media.id, + sourceType: media.sourceType, + sourceID: media.sourceID, + sourceData: media.sourceData, + artist: media.artist, + title: media.title, + duration: media.duration, + thumbnail: media.thumbnail, + }, + createdAt: playlistItem.createdAt, + }; +} /** * @typedef {object} GetPlaylistsQuery - * @prop {string} contains + * @prop {MediaID} [contains] */ /** @@ -23,9 +59,7 @@ async function getPlaylists(req) { let playlists; if (contains) { - const containsID = new ObjectId(contains); - - playlists = await uw.playlists.getPlaylistsContainingMedia(containsID, { author: user._id }); + playlists = await uw.playlists.getPlaylistsContainingMedia(contains, { author: user.id }); } else { playlists = await uw.playlists.getUserPlaylists(user); } @@ -38,7 +72,7 @@ async function getPlaylists(req) { /** * @typedef {object} GetPlaylistParams - * @prop {string} id + * @prop {PlaylistID} id */ /** @@ -49,7 +83,7 @@ async function getPlaylist(req) { const { playlists } = req.uwave; const { id } = req.params; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); @@ -74,24 +108,22 @@ async function createPlaylist(req) { const { name } = req.body; const { playlists } = req.uwave; - const playlist = await playlists.createPlaylist(user, { + const { playlist, active } = await playlists.createPlaylist(user, { name, }); - const activeID = user.activePlaylist ? user.activePlaylist.toString() : undefined; - return toItemResponse( serializePlaylist(playlist), { url: req.fullUrl, - meta: { active: activeID === playlist.id }, + meta: { active }, }, ); } /** * @typedef {object} DeletePlaylistParams - * @prop {string} id + * @prop {PlaylistID} id */ /** @@ -102,7 +134,7 @@ async function deletePlaylist(req) { const { id } = req.params; const { playlists } = req.uwave; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } @@ -116,8 +148,7 @@ const patchableKeys = ['name', 'description']; /** * @typedef {object} UpdatePlaylistParams - * @prop {string} id - * + * @prop {PlaylistID} id * @typedef {Record} UpdatePlaylistBody */ @@ -138,23 +169,22 @@ async function updatePlaylist(req) { } }); - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - await playlists.updatePlaylist(playlist, patch); + const updatedPlaylist = await playlists.updatePlaylist(playlist, patch); return toItemResponse( - serializePlaylist(playlist), + serializePlaylist(updatedPlaylist), { url: req.fullUrl }, ); } /** * @typedef {object} RenamePlaylistParams - * @prop {string} id - * + * @prop {PlaylistID} id * @typedef {object} RenamePlaylistBody * @prop {string} name */ @@ -169,22 +199,22 @@ async function renamePlaylist(req) { const { name } = req.body; const { playlists } = req.uwave; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - await playlists.updatePlaylist(playlist, { name }); + const updatedPlaylist = await playlists.updatePlaylist(playlist, { name }); return toItemResponse( - serializePlaylist(playlist), + serializePlaylist(updatedPlaylist), { url: req.fullUrl }, ); } /** * @typedef {object} ActivatePlaylistParams - * @prop {string} id + * @prop {PlaylistID} id */ /** @@ -192,24 +222,25 @@ async function renamePlaylist(req) { */ async function activatePlaylist(req) { const { user } = req; - const { playlists } = req.uwave; + const { db, playlists } = req.uwave; const { id } = req.params; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - user.activePlaylist = playlist._id; - await user.save(); + await db.updateTable('users') + .where('id', '=', user.id) + .set({ activePlaylistID: playlist.id }) + .execute(); return toItemResponse({}); } /** * @typedef {object} GetPlaylistItemsParams - * @prop {string} id - * + * @prop {PlaylistID} id * @typedef {import('../types.js').PaginationQuery & { filter?: string }} GetPlaylistItemsQuery */ @@ -224,7 +255,7 @@ async function getPlaylistItems(req) { const filter = req.query.filter ?? undefined; const pagination = getOffsetPagination(req.query); - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } @@ -241,18 +272,14 @@ async function getPlaylistItems(req) { /** * @typedef {import('../plugins/playlists.js').PlaylistItemDesc} PlaylistItemDesc - * * @typedef {object} AddPlaylistItemsParams - * @prop {string} id - * + * @prop {PlaylistID} id * @typedef {object} AtPosition * @prop {'start'|'end'} at * @prop {undefined} after - * * @typedef {object} AfterPosition * @prop {undefined} at - * @prop {string|-1} after - * + * @prop {PlaylistItemID|-1} after * @typedef {{ items: PlaylistItemDesc[] } & (AtPosition | AfterPosition)} AddPlaylistItemsBody */ @@ -266,28 +293,29 @@ async function addPlaylistItems(req) { const { id } = req.params; const { at, after, items } = req.body; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - /** @type {import('mongodb').ObjectId|null} */ - let afterID = null; - if (at === 'start') { - afterID = null; - } else if (at === 'end' || after === -1) { - afterID = playlist.media[playlist.size - 1]; - } else if (after !== null) { - afterID = new ObjectId(after); + let options; + if (at === 'start' || at === 'end') { + options = { at }; + } else if (after === -1) { + options = { at: /** @type {const} */ ('end') }; + } else if (after == null) { + options = { at: /** @type {const} */ ('start') }; + } else { + options = { after }; } const { added, afterID: actualAfterID, playlistSize, - } = await playlists.addPlaylistItems(playlist, items, { after: afterID }); + } = await playlists.addPlaylistItems(playlist, items, options); - return toListResponse(added, { + return toListResponse(added.map(serializePlaylistItem), { included: { media: ['media'], }, @@ -300,10 +328,9 @@ async function addPlaylistItems(req) { /** * @typedef {object} RemovePlaylistItemsParams - * @prop {string} id - * + * @prop {PlaylistID} id * @typedef {object} RemovePlaylistItemsBody - * @prop {string[]} items + * @prop {PlaylistItemID[]} items */ /** @@ -316,12 +343,12 @@ async function removePlaylistItems(req) { const { id } = req.params; const { items } = req.body; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - await playlists.removePlaylistItems(playlist, items.map((item) => new ObjectId(item))); + await playlists.removePlaylistItems(playlist, items); return toItemResponse({}, { meta: { @@ -332,9 +359,8 @@ async function removePlaylistItems(req) { /** * @typedef {object} MovePlaylistItemsParams - * @prop {string} id - * - * @typedef {{ items: string[] } & (AtPosition | AfterPosition)} MovePlaylistItemsBody + * @prop {PlaylistID} id + * @typedef {{ items: PlaylistItemID[] } & (AtPosition | AfterPosition)} MovePlaylistItemsBody */ /** @@ -347,30 +373,30 @@ async function movePlaylistItems(req) { const { id } = req.params; const { at, after, items } = req.body; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - /** @type {import('mongodb').ObjectId|null} */ - let afterID = null; - if (at === 'start') { - afterID = null; - } else if (at === 'end' || after === -1) { - afterID = playlist.media[playlist.size - 1]; - } else if (after !== null) { - afterID = new ObjectId(after); + let options; + if (at === 'start' || at === 'end') { + options = { at }; + } else if (after === -1) { + options = { at: /** @type {const} */ ('end') }; + } else if (after == null) { + options = { at: /** @type {const} */ ('start') }; + } else { + options = { after }; } - const itemIDs = items.map((item) => new ObjectId(item)); - const result = await playlists.movePlaylistItems(playlist, itemIDs, { afterID }); + const result = await playlists.movePlaylistItems(playlist, items, options); return toItemResponse(result, { url: req.fullUrl }); } /** * @typedef {object} ShufflePlaylistItemsParams - * @prop {string} id + * @prop {PlaylistID} id */ /** @@ -381,7 +407,7 @@ async function shufflePlaylistItems(req) { const { playlists } = req.uwave; const { id } = req.params; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } @@ -393,8 +419,8 @@ async function shufflePlaylistItems(req) { /** * @typedef {object} GetPlaylistItemParams - * @prop {string} id - * @prop {string} itemID + * @prop {PlaylistID} id + * @prop {PlaylistItemID} itemID */ /** @@ -405,24 +431,20 @@ async function getPlaylistItem(req) { const { playlists } = req.uwave; const { id, itemID } = req.params; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - const item = await playlists.getPlaylistItem(playlist, new ObjectId(itemID)); - if (!item) { - throw new PlaylistItemNotFoundError({ playlist, id: itemID }); - } + const { playlistItem, media } = await playlists.getPlaylistItem(playlist, itemID); - return toItemResponse(item, { url: req.fullUrl }); + return toItemResponse(legacyPlaylistItem(playlistItem, media), { url: req.fullUrl }); } /** * @typedef {object} UpdatePlaylistItemParams - * @prop {string} id - * @prop {string} itemID - * + * @prop {PlaylistID} id + * @prop {PlaylistItemID} itemID * @typedef {object} UpdatePlaylistItemBody * @prop {string} [artist] * @prop {string} [title] @@ -449,21 +471,21 @@ async function updatePlaylistItem(req) { end, }; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - const item = await playlists.getPlaylistItem(playlist, new ObjectId(itemID)); - const updatedItem = await playlists.updatePlaylistItem(item, patch); + const { playlistItem, media } = await playlists.getPlaylistItem(playlist, itemID); + const updatedItem = await playlists.updatePlaylistItem(playlistItem, patch); - return toItemResponse(updatedItem, { url: req.fullUrl }); + return toItemResponse(legacyPlaylistItem(updatedItem, media), { url: req.fullUrl }); } /** * @typedef {object} RemovePlaylistItemParams - * @prop {string} id - * @prop {string} itemID + * @prop {PlaylistID} id + * @prop {PlaylistItemID} itemID */ /** @@ -474,14 +496,14 @@ async function removePlaylistItem(req) { const { playlists } = req.uwave; const { id, itemID } = req.params; - const playlist = await playlists.getUserPlaylist(user, new ObjectId(id)); + const playlist = await playlists.getUserPlaylist(user, id); if (!playlist) { throw new PlaylistNotFoundError({ id }); } - const result = await playlists.removePlaylistItems(playlist, [new ObjectId(itemID)]); + await playlists.removePlaylistItems(playlist, [itemID]); - return toItemResponse(result, { url: req.fullUrl }); + return toItemResponse({}, { url: req.fullUrl }); } export { diff --git a/src/controllers/search.js b/src/controllers/search.js index 6d39735f..ddad03e7 100644 --- a/src/controllers/search.js +++ b/src/controllers/search.js @@ -4,9 +4,11 @@ import toListResponse from '../utils/toListResponse.js'; const { isEqual } = lodash; -/** @typedef {import('mongodb').ObjectId} ObjectId */ -/** @typedef {import('../models/index.js').Playlist} Playlist */ -/** @typedef {import('../models/index.js').Media} Media */ +/** @typedef {import('../schema.js').UserID} UserID */ +/** @typedef {import('../schema.js').PlaylistID} PlaylistID */ +/** @typedef {import('../schema.js').MediaID} MediaID */ +/** @typedef {import('../schema.js').Playlist} Playlist */ +/** @typedef {import('../schema.js').Media} Media */ /** @typedef {import('../plugins/playlists.js').PlaylistItemDesc} PlaylistItemDesc */ // TODO should be deprecated once the Web client uses the better single-source route. @@ -37,33 +39,27 @@ async function searchAll(req) { /** * @param {import('../Uwave.js').default} uw - * @param {Map} updates + * @param {Map} updates */ -async function updateSourceData(uw, updates) { - const { Media } = uw.models; - const ops = []; - uw.logger.debug({ ns: 'uwave:search', forMedia: [...updates.keys()] }, 'updating source data'); - for (const [id, sourceData] of updates.entries()) { - ops.push({ - updateOne: { - filter: { _id: id }, - update: { - $set: { sourceData }, - }, - }, - }); - } - await Media.bulkWrite(ops); +function updateSourceData(uw, updates) { + return uw.db.transaction().execute(async (tx) => { + uw.logger.debug({ ns: 'uwave:search', forMedia: [...updates.keys()] }, 'updating source data'); + for (const [id, sourceData] of updates.entries()) { + await tx.updateTable('media') + .where('id', '=', id) + .set({ sourceData }) + .executeTakeFirst(); + } + }); } /** * @typedef {object} SearchParams * @prop {string} source - * * @typedef {object} SearchQuery * @prop {string} query * @prop {string} [include] -*/ + */ /** * @type {import('../types.js').AuthenticatedController} @@ -73,7 +69,7 @@ async function search(req) { const { source: sourceName } = req.params; const { query, include } = req.query; const uw = req.uwave; - const { Media } = uw.models; + const db = uw.db; const source = uw.source(sourceName); if (!source) { @@ -91,23 +87,23 @@ async function search(req) { // Track medias whose `sourceData` property no longer matches that from the source. // This can happen because the media was actually changed, but also because of new // features in the source implementation. - /** @type {Map} */ + /** @type {Map} */ const mediasNeedSourceDataUpdate = new Map(); - /** @type {Media[]} */ - const mediasInSearchResults = await Media.find({ - sourceType: sourceName, - sourceID: { $in: Array.from(searchResultsByID.keys()) }, - }); + const mediasInSearchResults = await db.selectFrom('media') + .select(['id', 'sourceType', 'sourceID', 'sourceData']) + .where('sourceType', '=', sourceName) + .where('sourceID', 'in', Array.from(searchResultsByID.keys())) + .execute(); - /** @type {Map} */ + /** @type {Map} */ const mediaBySourceID = new Map(); mediasInSearchResults.forEach((media) => { mediaBySourceID.set(media.sourceID, media); const freshMedia = searchResultsByID.get(media.sourceID); if (freshMedia && !isEqual(media.sourceData, freshMedia.sourceData)) { - mediasNeedSourceDataUpdate.set(media._id, freshMedia.sourceData); + mediasNeedSourceDataUpdate.set(media.id, freshMedia.sourceData); } }); @@ -119,8 +115,8 @@ async function search(req) { // Only include related playlists if requested if (typeof include === 'string' && include.split(',').includes('playlists')) { const playlistsByMediaID = await uw.playlists.getPlaylistsContainingAnyMedia( - mediasInSearchResults.map((media) => media._id), - { author: user._id }, + mediasInSearchResults.map((media) => media.id), + { author: user.id }, ).catch((error) => { uw.logger.error({ ns: 'uwave:search', err: error }, 'playlists containing media lookup failed'); // just omit the related playlists if we timed out or crashed @@ -130,7 +126,7 @@ async function search(req) { searchResults.forEach((result) => { const media = mediaBySourceID.get(String(result.sourceID)); if (media) { - result.inPlaylists = playlistsByMediaID.get(media._id.toString()); + result.inPlaylists = playlistsByMediaID.get(media.id); } }); diff --git a/src/controllers/users.js b/src/controllers/users.js index bce57e82..9c484a1a 100644 --- a/src/controllers/users.js +++ b/src/controllers/users.js @@ -12,6 +12,10 @@ import toPaginatedResponse from '../utils/toPaginatedResponse.js'; import beautifyDuplicateKeyError from '../utils/beautifyDuplicateKeyError.js'; import { muteUser, unmuteUser } from './chat.js'; +/** + * @typedef {import('../schema').UserID} UserID + */ + /** * @typedef {object} GetUsersQuery * @prop {string} filter @@ -36,7 +40,7 @@ async function getUsers(req) { /** * @typedef {object} GetUserParams - * @prop {string} id + * @prop {UserID} id */ /** @@ -58,7 +62,7 @@ async function getUser(req) { /** * @typedef {object} GetUserRolesParams - * @prop {string} id + * @prop {UserID} id */ /** @@ -82,7 +86,7 @@ async function getUserRoles(req) { /** * @typedef {object} AddUserRoleParams - * @prop {string} id + * @prop {UserID} id * @prop {string} role */ @@ -94,7 +98,7 @@ async function addUserRole(req) { const { id, role } = req.params; const { acl, users } = req.uwave; - const selfHasRole = await acl.isAllowed(moderator, role); + const selfHasRole = moderator.roles.includes('*') || moderator.roles.includes(role); if (!selfHasRole) { throw new PermissionError({ requiredRole: role }); } @@ -113,7 +117,7 @@ async function addUserRole(req) { /** * @typedef {object} RemoveUserRoleParams - * @prop {string} id + * @prop {UserID} id * @prop {string} role */ @@ -125,7 +129,7 @@ async function removeUserRole(req) { const { id, role } = req.params; const { acl, users } = req.uwave; - const selfHasRole = await acl.isAllowed(moderator, role); + const selfHasRole = moderator.roles.includes('*') || moderator.roles.includes(role); if (!selfHasRole) { throw new PermissionError({ requiredRole: role }); } @@ -144,8 +148,7 @@ async function removeUserRole(req) { /** * @typedef {object} ChangeUsernameParams - * @prop {string} id - * + * @prop {UserID} id * @typedef {object} ChangeUsernameBody * @prop {string} username */ @@ -182,7 +185,7 @@ async function changeAvatar() { /** * @param {import('../Uwave.js').default} uw - * @param {import('mongodb').ObjectId} userID + * @param {UserID} userID */ async function disconnectUser(uw, userID) { await skipIfCurrentDJ(uw, userID); @@ -193,14 +196,14 @@ async function disconnectUser(uw, userID) { // Ignore } - await uw.redis.lrem('users', 0, userID.toString()); + await uw.redis.lrem('users', 0, userID); - uw.publish('user:leave', { userID: userID.toString() }); + uw.publish('user:leave', { userID }); } /** * @typedef {object} GetHistoryParams - * @prop {string} id + * @prop {UserID} id */ /** diff --git a/src/controllers/waitlist.js b/src/controllers/waitlist.js index 26ed5026..9f53d8ac 100644 --- a/src/controllers/waitlist.js +++ b/src/controllers/waitlist.js @@ -1,6 +1,10 @@ import toItemResponse from '../utils/toItemResponse.js'; import toListResponse from '../utils/toListResponse.js'; +/** + * @typedef {import('../schema.js').UserID} UserID + */ + /** * @type {import('../types.js').Controller} */ @@ -14,7 +18,7 @@ async function getWaitlist(req) { /** * @typedef {object} AddToWaitlistBody - * @prop {string} userID + * @prop {UserID} userID */ /** @@ -37,7 +41,7 @@ async function addToWaitlist(req) { /** * @typedef {object} MoveWaitlistBody - * @prop {string} userID + * @prop {UserID} userID * @prop {number} position */ @@ -58,7 +62,7 @@ async function moveWaitlist(req) { /** * @typedef {object} RemoveFromWaitlistParams - * @prop {string} id + * @prop {UserID} id */ /** diff --git a/src/email.js b/src/email.js deleted file mode 100644 index 697e18cb..00000000 --- a/src/email.js +++ /dev/null @@ -1,27 +0,0 @@ -import nodemailer from 'nodemailer'; - -/** - * @param {string} emailAddress - * @param {{ - * mailTransport?: import('nodemailer').Transport | import('nodemailer').TransportOptions, - * email: import('nodemailer').SendMailOptions, - * }} options - */ -async function sendEmail(emailAddress, options) { - const smtpOptions = { - host: 'localhost', - port: 25, - debug: true, - tls: { - rejectUnauthorized: false, - }, - }; - - const transporter = nodemailer.createTransport(options.mailTransport ?? smtpOptions); - - const mailOptions = { to: emailAddress, ...options.email }; - - await transporter.sendMail(mailOptions); -} - -export default sendEmail; diff --git a/src/middleware/protect.js b/src/middleware/protect.js index a63327d4..bdbf5e1b 100644 --- a/src/middleware/protect.js +++ b/src/middleware/protect.js @@ -2,17 +2,17 @@ import { LoginRequiredError, PermissionError } from '../errors/index.js'; import wrapMiddleware from '../utils/wrapMiddleware.js'; /** - * @param {string} [role] + * @param {import('../schema.js').Permission} [permission] */ -function protect(role) { +function protect(permission) { return wrapMiddleware(async (req) => { const { acl } = req.uwave; if (!req.user) { throw new LoginRequiredError(); } - if (role && !(await acl.isAllowed(req.user, role))) { - throw new PermissionError({ requiredRole: role }); + if (permission && !(await acl.isAllowed(req.user, permission))) { + throw new PermissionError({ requiredRole: permission }); } }); } diff --git a/src/middleware/requireActiveConnection.js b/src/middleware/requireActiveConnection.js index 19dbdcb6..17006bf6 100644 --- a/src/middleware/requireActiveConnection.js +++ b/src/middleware/requireActiveConnection.js @@ -6,7 +6,7 @@ const { BadRequest } = httpErrors; function requireActiveConnection() { /** * @param {import('../Uwave.js').default} uwave - * @param {import('../models/index.js').User} user + * @param {import('../schema.js').User} user */ async function isConnected(uwave, user) { const onlineIDs = await uwave.redis.lrange('users', 0, -1); diff --git a/src/middleware/schema.js b/src/middleware/schema.js index 4e2017d0..6ac7db33 100644 --- a/src/middleware/schema.js +++ b/src/middleware/schema.js @@ -25,7 +25,6 @@ alwaysTrue.errors = null; * @prop {import('ajv').SchemaObject} [body] * @prop {import('ajv').SchemaObject} [params] * @prop {import('ajv').SchemaObject} [query] - * * @param {Schemas} schemas * @returns {import('express').RequestHandler} */ diff --git a/src/migrations/001-activePlaylistState.cjs b/src/migrations/001-activePlaylistState.cjs index 4b668bd1..17dabf94 100644 --- a/src/migrations/001-activePlaylistState.cjs +++ b/src/migrations/001-activePlaylistState.cjs @@ -6,62 +6,21 @@ 'use strict'; -const { ObjectId } = require('mongoose').mongo; -const { zip } = require('lodash'); - -const rxObjectID = /^[0-9a-f]{24}$/; - // Cannot use `@type {import('umzug').MigrationFn}` // due to https://github.com/microsoft/TypeScript/issues/43160 /** * @param {import('umzug').MigrationParams} params */ -async function up({ context: uw }) { - const { User } = uw.models; - - const ops = []; - for await (const keys of uw.redis.scanStream({ match: 'playlist:*' })) { - if (keys.length === 0) { - continue; - } - - const values = await uw.redis.mget(keys); - for (const [key, playlistID] of zip(keys, values)) { - const userID = key.replace(/^playlist:/, ''); - if (!playlistID || !rxObjectID.test(userID) || !rxObjectID.test(playlistID)) { - // must be corrupt if it isn't an object ID. - continue; - } - - ops.push({ - updateOne: { - filter: { _id: new ObjectId(userID) }, - update: { - $set: { activePlaylist: new ObjectId(playlistID) }, - }, - }, - }); - } - await uw.redis.unlink(keys); - } - - await User.bulkWrite(ops); +async function up() { + // snip } /** * @param {import('umzug').MigrationParams} params */ -async function down({ context: uw }) { - const { User } = uw.models; - - const users = User.find({ activePlaylist: { $ne: null } }); - - for await (const user of users) { - if (!user.activePlaylist) return; - - await uw.redis.set(`playlist:${user._id}`, user.activePlaylist.toString()); - } +async function down() { + // snip } module.exports = { up, down }; diff --git a/src/migrations/002-sql.cjs b/src/migrations/002-sql.cjs new file mode 100644 index 00000000..6c39bc5a --- /dev/null +++ b/src/migrations/002-sql.cjs @@ -0,0 +1,136 @@ +'use strict'; + +const { sql } = require('kysely'); + +const now = sql`(strftime('%FT%TZ', 'now'))`; +const emptyArray = sql`(jsonb('[]'))`; + +/** + * @param {import('umzug').MigrationParams} params + */ +async function up({ context: uw }) { + const { db } = uw; + + await db.schema.createTable('configuration') + .addColumn('name', 'text', (col) => col.primaryKey()) + .addColumn('value', 'jsonb') + .execute(); + + await db.schema.createTable('media') + .addColumn('id', 'uuid', (col) => col.primaryKey()) + .addColumn('source_type', 'text', (col) => col.notNull()) + .addColumn('source_id', 'text', (col) => col.notNull()) + .addColumn('source_data', 'jsonb') + .addColumn('artist', 'text', (col) => col.notNull()) + .addColumn('title', 'text', (col) => col.notNull()) + .addColumn('duration', 'integer', (col) => col.notNull()) + .addColumn('thumbnail', 'text', (col) => col.notNull()) + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('updated_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addUniqueConstraint('media_source_key', ['source_type', 'source_id']) + .execute(); + + await db.schema.createTable('users') + .addColumn('id', 'uuid', (col) => col.primaryKey()) + .addColumn('username', 'text', (col) => col.notNull().unique()) + .addColumn('email', 'text') + .addColumn('password', 'text') + .addColumn('slug', 'text', (col) => col.notNull().unique()) + .addColumn('avatar', 'text') + .addColumn('pending_activation', 'boolean', (col) => col.defaultTo(null)) + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('updated_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addUniqueConstraint('user_email', ['email']) + .execute(); + + await db.schema.createTable('roles') + .addColumn('id', 'text', (col) => col.primaryKey()) + .addColumn('permissions', 'jsonb', (col) => col.notNull()) + .execute(); + + await db.schema.createTable('user_roles') + .addColumn('userID', 'uuid', (col) => col.references('users.id')) + .addColumn('role', 'text', (col) => col.references('roles.id')) + .addUniqueConstraint('unique_user_role', ['userID', 'role']) + .execute(); + + await db.schema.createTable('bans') + .addColumn('user_id', 'uuid', (col) => col.primaryKey().references('users.id')) + .addColumn('moderator_id', 'uuid', (col) => col.notNull().references('users.id')) + .addColumn('expires_at', 'timestamp') + .addColumn('reason', 'text') + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('updated_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .execute(); + + await db.schema.createTable('mutes') + .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id')) + .addColumn('moderator_id', 'uuid', (col) => col.notNull().references('users.id')) + .addColumn('expires_at', 'timestamp', (col) => col.notNull()) + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('updated_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .execute(); + + await db.schema.createTable('auth_services') + .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id')) + .addColumn('service', 'text', (col) => col.notNull()) + .addColumn('service_id', 'text', (col) => col.notNull()) + .addColumn('service_avatar', 'text') + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('updated_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addUniqueConstraint('user_auth_service', ['user_id', 'service']) + .addUniqueConstraint('auth_service', ['service', 'service_id']) + .execute(); + + await db.schema.createTable('playlists') + .addColumn('id', 'uuid', (col) => col.primaryKey()) + .addColumn('name', 'text', (col) => col.notNull()) + .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id')) + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('updated_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('items', 'jsonb', (col) => col.notNull().defaultTo(emptyArray)) + .execute(); + + await db.schema.createTable('playlist_items') + .addColumn('id', 'uuid', (col) => col.primaryKey()) + .addColumn('playlist_id', 'uuid', (col) => col.notNull().references('playlists.id')) + .addColumn('media_id', 'uuid', (col) => col.notNull().references('media.id')) + .addColumn('artist', 'text', (col) => col.notNull()) + .addColumn('title', 'text', (col) => col.notNull()) + .addColumn('start', 'integer', (col) => col.notNull()) + .addColumn('end', 'integer', (col) => col.notNull()) + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .addColumn('updated_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .execute(); + + await db.schema.createTable('history_entries') + .addColumn('id', 'uuid', (col) => col.primaryKey()) + .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id')) + .addColumn('media_id', 'uuid', (col) => col.notNull().references('media.id')) + .addColumn('artist', 'text', (col) => col.notNull()) + .addColumn('title', 'text', (col) => col.notNull()) + .addColumn('start', 'integer', (col) => col.notNull()) + .addColumn('end', 'integer', (col) => col.notNull()) + .addColumn('source_data', 'jsonb') + .addColumn('created_at', 'timestamp', (col) => col.notNull().defaultTo(now)) + .execute(); + + await db.schema.createTable('feedback') + .addColumn('history_entry_id', 'uuid', (col) => col.notNull().references('historyEntries.id')) + .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id')) + .addColumn('vote', 'integer', (col) => col.defaultTo(0)) + .addColumn('favorite', 'integer', (col) => col.defaultTo(0)) + .addUniqueConstraint('one_vote_per_user', ['history_entry_id', 'user_id']) + .execute(); + + await db.schema.alterTable('users') + .addColumn('active_playlist_id', 'uuid', (col) => col.references('playlists.id')) + .execute(); +} + +/** + * @param {import('umzug').MigrationParams} params + */ +async function down() {} + +module.exports = { up, down }; diff --git a/src/migrations/003-populate-sql.cjs b/src/migrations/003-populate-sql.cjs new file mode 100644 index 00000000..7abd9744 --- /dev/null +++ b/src/migrations/003-populate-sql.cjs @@ -0,0 +1,252 @@ +'use strict'; + +const { randomUUID } = require('node:crypto'); +const mongoose = require('mongoose'); +const { sql } = require('kysely'); + +/** @param {unknown} value */ +function jsonb(value) { + return sql`jsonb(${JSON.stringify(value)})`; +} + +/** + * @param {import('umzug').MigrationParams} params + */ +async function up({ context: uw }) { + const { db } = uw; + + if (uw.options.mongo == null) { + return; + } + + const mongo = await mongoose.connect(uw.options.mongo).catch(() => null); + if (mongo == null) { + return; + } + + const models = { + AclRole: mongo.model('AclRole', await import('../models/AclRole.js').then((m) => m.default)), + Authentication: mongo.model('Authentication', await import('../models/Authentication.js').then((m) => m.default)), + Config: mongo.model('Config', await import('../models/Config.js').then((m) => m.default)), + HistoryEntry: mongo.model('History', await import('../models/History.js').then((m) => m.default)), + Media: mongo.model('Media', await import('../models/Media.js').then((m) => m.default)), + Migration: mongo.model('Migration', await import('../models/Migration.js').then((m) => m.default)), + Playlist: mongo.model('Playlist', await import('../models/Playlist.js').then((m) => m.default)), + PlaylistItem: mongo.model('PlaylistItem', await import('../models/PlaylistItem.js').then((m) => m.default)), + User: mongo.model('User', await import('../models/User.js').then((m) => m.default)), + }; + + // For now redis is still required. + const motd = await uw.redis.get('motd'); + + /** @type {Map} */ + const idMap = new Map(); + + await db.transaction().execute(async (tx) => { + for await (const config of models.Config.find().lean()) { + const { _id: name, ...value } = config; + await tx.insertInto('configuration') + .values({ name, value: jsonb(value) }) + .execute(); + } + + if (motd != null && motd !== '') { + await tx.insertInto('configuration') + .values({ name: 'u-wave:motd', value: jsonb(motd) }) + .execute(); + } + + for await (const media of models.Media.find().lean()) { + const id = randomUUID(); + await tx.insertInto('media') + .values({ + id, + sourceType: media.sourceType, + sourceID: media.sourceID, + sourceData: jsonb(media.sourceData), + artist: media.artist, + title: media.title, + duration: media.duration, + thumbnail: media.thumbnail, + createdAt: media.createdAt.toISOString(), + updatedAt: media.updatedAt.toISOString(), + }) + .onConflict((conflict) => conflict.columns(['sourceType', 'sourceID']).doUpdateSet({ + updatedAt: (eb) => eb.ref('excluded.updatedAt'), + })) + .execute(); + + idMap.set(media._id.toString(), id); + } + + const roles = await models.AclRole.find().lean(); + /** @type {Record} */ + const roleMap = Object.create(null); + for (const role of roles) { + if (role._id.includes('.') || role._id === '*') { + continue; + } + + roleMap[role._id] = role.roles ?? []; + } + const permissionRows = Object.entries(roleMap).map(([role, permissions]) => ({ + id: role, + permissions: jsonb( + permissions.flatMap((perm) => perm.includes('.') || perm === '*' ? [perm] : roleMap[perm]), + ), + })); + + if (permissionRows.length > 0) { + await tx.insertInto('roles') + .values(permissionRows) + .execute(); + } + + for await (const user of models.User.find().lean()) { + const userID = randomUUID(); + idMap.set(user._id.toString(), userID); + + await tx.insertInto('users') + .values({ + id: userID, + username: user.username, + slug: user.slug, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }) + .execute(); + + if (user.roles.length > 0) { + await tx.insertInto('userRoles') + .values(user.roles.map((role) => ({ userID, role }))) + .execute(); + } + + for await (const playlist of models.Playlist.where('author', user._id).lean()) { + const playlistID = randomUUID(); + idMap.set(playlist._id.toString(), playlistID); + + await tx.insertInto('playlists') + .values({ + id: playlistID, + name: playlist.name, + userID, + createdAt: playlist.createdAt.toISOString(), + updatedAt: playlist.updatedAt.toISOString(), + }) + .execute(); + + const items = []; + for (const itemMongoID of playlist.media) { + const itemID = randomUUID(); + idMap.set(itemMongoID.toString(), itemID); + + const item = await models.PlaylistItem.findById(itemMongoID).lean(); + await tx.insertInto('playlistItems') + .values({ + id: itemID, + playlistID, + mediaID: idMap.get(item.media.toString()), + artist: item.artist, + title: item.title, + start: item.start, + end: item.end, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + }) + .execute(); + + items.push(itemID); + } + + await tx.updateTable('playlists') + .where('id', '=', playlistID) + .set({ items: jsonb(items) }) + .execute(); + } + } + + for await (const entry of models.Authentication.find().lean()) { + const userID = idMap.get(entry.user.toString()); + if (userID == null) { + throw new Error('Migration failure: unknown user ID'); + } + + if (entry.email != null) { + await tx.updateTable('users') + .where('id', '=', userID) + .set({ email: entry.email }) + .execute(); + } + + if (entry.hash != null) { + await tx.updateTable('users') + .where('id', '=', userID) + .set({ password: entry.hash }) + .execute(); + } + } + + for await (const entry of models.HistoryEntry.find().lean()) { + const entryID = randomUUID(); + idMap.set(entry._id.toString(), entryID); + const userID = idMap.get(entry.user.toString()); + const mediaID = idMap.get(entry.media.media.toString()); + await tx.insertInto('historyEntries') + .values({ + id: entryID, + mediaID, + userID, + artist: entry.media.artist, + title: entry.media.title, + start: entry.media.start, + end: entry.media.end, + sourceData: jsonb(entry.media.sourceData), + createdAt: entry.playedAt.toISOString(), + }) + .execute(); + + const feedback = new Map(); + for (const id of entry.upvotes) { + feedback.set(id.toString(), { + historyEntryID: entryID, + userID: idMap.get(id.toString()), + vote: 1, + }); + } + for (const id of entry.downvotes) { + feedback.set(id.toString(), { + historyEntryID: entryID, + userID: idMap.get(id.toString()), + vote: -1, + }); + } + for (const id of entry.favorites) { + const entry = feedback.get(id.toString()); + if (entry != null) { + entry.favorite = 1; + } else { + feedback.set(id.toString(), { + historyEntryID: entryID, + userID: idMap.get(id.toString()), + favorite: 1, + }); + } + } + + if (feedback.size > 0) { + await tx.insertInto('feedback') + .values(Array.from(feedback.values())) + .execute(); + } + } + }) + .finally(() => mongo.disconnect()); +} + +/** + * @param {import('umzug').MigrationParams} params + */ +async function down() {} + +module.exports = { up, down }; diff --git a/src/models/AclRole.js b/src/models/AclRole.js index b32c5f2a..ec8c4b22 100644 --- a/src/models/AclRole.js +++ b/src/models/AclRole.js @@ -6,7 +6,6 @@ const { Schema } = mongoose; * @typedef {object} LeanAclRole * @prop {string} _id * @prop {string[]} roles - * * @typedef {mongoose.Document & LeanAclRole} AclRole */ diff --git a/src/models/Authentication.js b/src/models/Authentication.js index 1e0f7b6e..50495999 100644 --- a/src/models/Authentication.js +++ b/src/models/Authentication.js @@ -12,7 +12,6 @@ const { Types } = mongoose.Schema; * @prop {string} [hash] * @prop {string} [id] * @prop {string} [avatar] - * * @typedef {mongoose.Document & * LeanAuthentication} Authentication */ diff --git a/src/models/Config.js b/src/models/Config.js index 9d8dae11..35c3e6be 100644 --- a/src/models/Config.js +++ b/src/models/Config.js @@ -5,7 +5,6 @@ const { Schema } = mongoose; /** * @typedef {object} LeanConfig * @prop {string} _id - * * @typedef {mongoose.Document & * LeanConfig} Config */ diff --git a/src/models/Media.js b/src/models/Media.js index 0a525bcb..437e3ee6 100644 --- a/src/models/Media.js +++ b/src/models/Media.js @@ -14,7 +14,6 @@ const { Schema } = mongoose; * @prop {string} thumbnail * @prop {Date} createdAt * @prop {Date} updatedAt - * * @typedef {mongoose.Document & LeanMedia} Media */ diff --git a/src/models/Migration.js b/src/models/Migration.js index 284e58ca..32160124 100644 --- a/src/models/Migration.js +++ b/src/models/Migration.js @@ -8,7 +8,6 @@ const { Schema } = mongoose; * @prop {string} migrationName * @prop {Date} createdAt * @prop {Date} updatedAt - * * @typedef {mongoose.Document & LeanMigration} Migration */ diff --git a/src/models/Playlist.js b/src/models/Playlist.js index 09a9d843..baaf17a2 100644 --- a/src/models/Playlist.js +++ b/src/models/Playlist.js @@ -12,7 +12,6 @@ const { Types } = mongoose.Schema; * @prop {import('mongodb').ObjectId[]} media * @prop {Date} createdAt * @prop {Date} updatedAt - * * @typedef {mongoose.Document & LeanPlaylist & { * readonly size: number * }} Playlist diff --git a/src/models/PlaylistItem.js b/src/models/PlaylistItem.js index 9e81bb84..7aab94ad 100644 --- a/src/models/PlaylistItem.js +++ b/src/models/PlaylistItem.js @@ -13,7 +13,6 @@ const { Types } = mongoose.Schema; * @prop {number} end * @prop {Date} createdAt * @prop {Date} updatedAt - * * @typedef {mongoose.Document & * LeanPlaylistItem} PlaylistItem */ diff --git a/src/models/User.js b/src/models/User.js index d1407eac..85db9807 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -29,7 +29,6 @@ const { Types } = mongoose.Schema; * @prop {number} role - Deprecated, do not use * @prop {number} level - Deprecated, do not use * @prop {boolean} exiled - Deprecated, do not use - * * @typedef {mongoose.Document & LeanUser} User */ diff --git a/src/models/index.js b/src/models/index.js deleted file mode 100644 index 3a0aa7d0..00000000 --- a/src/models/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import aclRoleSchema from './AclRole.js'; -import authenticationSchema from './Authentication.js'; -import configSchema from './Config.js'; -import historySchema from './History.js'; -import mediaSchema from './Media.js'; -import migrationSchema from './Migration.js'; -import playlistSchema from './Playlist.js'; -import playlistItemSchema from './PlaylistItem.js'; -import userSchema from './User.js'; - -/** - * @typedef {import('./AclRole.js').AclRole} AclRole - * @typedef {import('./Authentication.js').Authentication} Authentication - * @typedef {import('./Config.js').Config} Config - * @typedef {import('./History.js').HistoryEntry} HistoryEntry - * @typedef {import('./Media.js').Media} Media - * @typedef {import('./Migration.js').Migration} Migration - * @typedef {import('./Playlist.js').Playlist} Playlist - * @typedef {import('./PlaylistItem.js').PlaylistItem} PlaylistItem - * @typedef {import('./User.js').User} User - * @typedef {{ - * AclRole: import('mongoose').Model, - * Authentication: import('mongoose').Model, - * Config: import('mongoose').Model, - * HistoryEntry: import('mongoose').Model, - * Media: import('mongoose').Model, - * Migration: import('mongoose').Model, - * Playlist: import('mongoose').Model, - * PlaylistItem: import('mongoose').Model, - * User: import('mongoose').Model, - * }} Models - */ - -/** - * @param {import('../Uwave.js').default} uw - */ -async function models(uw) { - uw.models = { - AclRole: uw.mongo.model('AclRole', aclRoleSchema), - Authentication: uw.mongo.model('Authentication', authenticationSchema), - Config: uw.mongo.model('Config', configSchema), - HistoryEntry: uw.mongo.model('History', historySchema), - Media: uw.mongo.model('Media', mediaSchema), - Migration: uw.mongo.model('Migration', migrationSchema), - Playlist: uw.mongo.model('Playlist', playlistSchema), - PlaylistItem: uw.mongo.model('PlaylistItem', playlistItemSchema), - User: uw.mongo.model('User', userSchema), - }; -} - -export default models; diff --git a/src/plugins/acl.js b/src/plugins/acl.js index ab2aac57..875ad6ec 100644 --- a/src/plugins/acl.js +++ b/src/plugins/acl.js @@ -1,22 +1,44 @@ +import { sql } from 'kysely'; import defaultRoles from '../config/defaultRoles.js'; import routes from '../routes/acl.js'; +import { jsonb, jsonEach } from '../utils/sqlite.js'; /** - * @typedef {import('../models/index.js').AclRole} AclRole - * @typedef {import('../models/index.js').User} User - * @typedef {{ roles: AclRole[] }} PopulateRoles - * @typedef {Omit & PopulateRoles} PopulatedAclRole + * @typedef {import('../schema.js').User} User + * @typedef {import('../schema.js').Permission} Permission */ -/** - * @param {AclRole|string} role - * @returns {string} - */ -function getRoleName(role) { - return typeof role === 'string' ? role : role.id; +/** @param {string} input */ +function p(input) { + return /** @type {Permission} */ (input); } - -const SUPER_ROLE = '*'; +export const Permissions = { + Super: p('*'), + MotdSet: p('motd.set'), + WaitlistJoin: p('waitlist.join'), + WaitlistJoinLocked: p('waitlist.join.locked'), + WaitlistLeave: p('waitlist.leave'), + WaitlistClear: p('waitlist.clear'), + WaitlistLock: p('waitlist.lock'), + WaitlistAdd: p('waitlist.add'), + WaitlistMove: p('waitlist.move'), + WaitlistRemove: p('waitlist.remove'), + SkipSelf: p('booth.skip.self'), + SkipOther: p('booth.skip.other'), + Vote: p('booth.vote'), + AclCreate: p('acl.create'), + AclDelete: p('acl.delete'), + ChatSend: p('chat.send'), + ChatDelete: p('chat.delete'), + ChatMute: p('chat.mute'), + ChatUnmute: p('chat.unmute'), + /** @param {string} role */ + ChatMention: (role) => p(`chat.mention.${role}`), + UserList: p('users.list'), + BanList: p('users.bans.list'), + BanAdd: p('users.bans.add'), + BanRemove: p('users.bans.remove'), +}; class Acl { #uw; @@ -32,119 +54,62 @@ class Acl { } async maybeAddDefaultRoles() { - const { AclRole } = this.#uw.models; - - const existingRoles = await AclRole.estimatedDocumentCount(); - this.#logger.debug({ roles: existingRoles }, 'existing roles'); - if (existingRoles === 0) { - this.#logger.info('no roles found, adding defaults'); - for (const [roleName, permissions] of Object.entries(defaultRoles)) { - await this.createRole(roleName, permissions); + const { db } = this.#uw; + + await db.transaction().execute(async (tx) => { + const { existingRoles } = await tx.selectFrom('roles') + .select((eb) => eb.fn.countAll().as('existingRoles')) + .executeTakeFirstOrThrow(); + this.#logger.debug({ roles: existingRoles }, 'existing roles'); + if (existingRoles === 0) { + this.#logger.info('no roles found, adding defaults'); + for (const [roleName, permissions] of Object.entries(defaultRoles)) { + await this.createRole(roleName, permissions, tx); + } } - } - } - - /** - * @param {string[]} names - * @param {{ create?: boolean }} [options] - * @returns {Promise} - * @private - */ - async getAclRoles(names, options = {}) { - const { AclRole } = this.#uw.models; - - /** @type {AclRole[]} */ - const existingRoles = await AclRole.find({ _id: { $in: names } }); - const newNames = names.filter((name) => ( - !existingRoles.some((role) => role.id === name) - )); - if (options.create && newNames.length > 0) { - const newRoles = await AclRole.create(newNames.map((name) => ({ _id: name }))); - existingRoles.push(...newRoles); - } - return existingRoles; + }); } /** - * @returns {Promise>} + * @returns {Promise>} */ - async getAllRoles() { - const { AclRole } = this.#uw.models; + async getAllRoles(tx = this.#uw.db) { + // TODO: `json()` should be strongly typed + const list = await tx.selectFrom('roles') + .select(['id', sql`json(permissions)`.as('permissions')]) + .execute(); - const roles = await AclRole.find().lean(); - return roles.reduce((map, role) => Object.assign(map, { - [role._id]: role.roles, - }), {}); - } + const roles = Object.fromEntries(list.map((role) => [ + role.id, + /** @type {Permission[]} */ (JSON.parse(/** @type {string} */ (role.permissions))), + ])); - /** - * @param {string[]} roleNames - * @returns {Promise} - * @private - */ - async getSubRoles(roleNames) { - const { AclRole } = this.#uw.models; - // Always returns 1 document. - /** @type {{ _id: 1, roles: string[] }[]} */ - const res = await AclRole.aggregate([ - { - $match: { - _id: { $in: roleNames }, - }, - }, - // Create a starting document of shape: {_id: 1, roles: roleNames} - // This way we can get a result document that has both our initial - // role names AND all subroles. - { - $group: { - _id: 1, - roles: { $addToSet: '$_id' }, - }, - }, - { - $graphLookup: { - from: 'acl_roles', - startWith: '$roles', - connectFromField: 'roles', - connectToField: '_id', - as: 'roles', - }, - }, - { $project: { roles: '$roles._id' } }, - ]); - return res.length === 1 ? res[0].roles.sort() : []; + return roles; } /** * @param {string} name - * @param {string[]} permissions + * @param {Permission[]} permissions */ - async createRole(name, permissions) { - const { AclRole } = this.#uw.models; - - const roles = await this.getAclRoles(permissions, { create: true }); - await AclRole.findByIdAndUpdate( - name, - { roles: roles.map((role) => role._id) }, - { upsert: true }, - ); - - // We have to fetch the permissions from the database to account for permissions - // that have sub-permissions of their own. - const allPermissions = await this.getSubRoles(roles.map(getRoleName)); - return { - name, - permissions: allPermissions, - }; + async createRole(name, permissions, tx = this.#uw.db) { + await tx.insertInto('roles') + .values({ id: name, permissions: jsonb(permissions) }) + .onConflict((conflict) => conflict.column('id').doUpdateSet({ permissions: jsonb(permissions) })) + .execute(); + + return { name, permissions }; } /** * @param {string} name */ - async deleteRole(name) { - const { AclRole } = this.#uw.models; - - await AclRole.deleteOne({ _id: name }); + async deleteRole(name, tx = this.#uw.db) { + await tx.deleteFrom('userRoles') + .where('role', '=', name) + .execute(); + await tx.deleteFrom('roles') + .where('id', '=', name) + .execute(); } /** @@ -152,18 +117,18 @@ class Acl { * @param {string[]} roleNames * @returns {Promise} */ - async allow(user, roleNames) { - const aclRoles = await this.getAclRoles(roleNames); - - aclRoles.forEach((role) => { - user.roles.push(role.id); - }); - - await user.save(); + async allow(user, roleNames, tx = this.#uw.db) { + const insertedRoles = await tx.insertInto('userRoles') + .values(roleNames.map((roleName) => ({ + userID: user.id, + role: roleName, + }))) + .returningAll() + .execute(); this.#uw.publish('acl:allow', { userID: user.id, - roles: aclRoles.map((role) => role.id), + roles: insertedRoles.map((row) => row.role), }); } @@ -172,48 +137,51 @@ class Acl { * @param {string[]} roleNames * @returns {Promise} */ - async disallow(user, roleNames) { - const aclRoles = await this.getAclRoles(roleNames); - /** @type {(roleName: string) => boolean} */ - const shouldRemove = (roleName) => aclRoles.some((remove) => remove.id === roleName); - user.roles = user.roles.filter((role) => !shouldRemove(getRoleName(role))); - await user.save(); - - this.#uw.publish('acl:disallow', { - userID: user.id, - roles: aclRoles.map((role) => role.id), - }); + async disallow(user, roleNames, tx = this.#uw.db) { + const deletedRoles = await tx.deleteFrom('userRoles') + .where('userID', '=', user.id) + .where('role', 'in', roleNames) + .returningAll() + .execute(); + + if (deletedRoles.length > 0) { + this.#uw.publish('acl:disallow', { + userID: user.id, + roles: deletedRoles.map((row) => row.role), + }); + } } /** * @param {User} user - * @returns {Promise} + * @returns {Promise} */ - async getAllPermissions(user) { - const roles = await this.getSubRoles(user.roles.map(getRoleName)); - return roles; + async getAllPermissions(user, tx = this.#uw.db) { + const permissions = await tx.selectFrom('userRoles') + .where('userID', '=', user.id) + .innerJoin('roles', 'roles.id', 'userRoles.role') + .innerJoin( + (eb) => jsonEach(eb.ref('roles.permissions')).as('permissions'), + (join) => join, + ) + .select('permissions.value') + .execute(); + + return permissions.map((perm) => perm.value); } /** * @param {User} user - * @param {string} permission + * @param {Permission} permission * @returns {Promise} */ - async isAllowed(user, permission) { - const { AclRole } = this.#uw.models; - - const role = await AclRole.findById(permission); - if (!role) { - return false; - } - - const userRoles = await this.getSubRoles(user.roles.map(getRoleName)); - const isAllowed = userRoles.includes(role.id) || userRoles.includes(SUPER_ROLE); + async isAllowed(user, permission, tx = this.#uw.db) { + const permissions = await this.getAllPermissions(user, tx); + const isAllowed = permissions.includes(permission) || permissions.includes(Permissions.Super); this.#logger.trace({ userId: user.id, - roleId: role.id, - userRoles, + permissions, isAllowed, }, 'user allowed check'); diff --git a/src/plugins/bans.js b/src/plugins/bans.js index da11c130..4663e853 100644 --- a/src/plugins/bans.js +++ b/src/plugins/bans.js @@ -1,30 +1,9 @@ import lodash from 'lodash'; -import escapeStringRegExp from 'escape-string-regexp'; import { UserNotFoundError } from '../errors/index.js'; import Page from '../Page.js'; +import { now } from '../utils/sqlite.js'; -const { clamp, omit } = lodash; - -/** - * @typedef {import('../models/index.js').User} User - * @typedef {import('../models/User.js').LeanUser} LeanUser - * @typedef {import('../models/User.js').LeanBanned} LeanBanned - * @typedef {LeanBanned & { user: Omit }} Ban - */ - -/** - * @param {User} user - */ -function isValidBan(user) { - if (!user.banned) { - return false; - } - // Permanent ban. - if (!user.banned.expiresAt) { - return true; - } - return user.banned.expiresAt.getTime() > Date.now(); -} +const { clamp } = lodash; class Bans { #uw; @@ -39,11 +18,22 @@ class Bans { /** * Check whether a user is currently banned. * - * @param {User} user A user object. + * @param {import('../schema.js').User} user A user object. */ async isBanned(user) { - return isValidBan(user); + const { db } = this.#uw; + + const ban = await db.selectFrom('bans') + .selectAll() + .where('userID', '=', user.id) + .where(({ or, eb }) => or([ + eb('expiresAt', 'is', null), + eb('expiresAt', '>', now), + ])) + .executeTakeFirst(); + + return ban != null; } /** @@ -51,10 +41,9 @@ class Bans { * * @param {string} [filter] Optional filter to search for usernames. * @param {{ offset?: number, limit?: number }} [pagination] A pagination object. - * @return {Promise>} */ async getBans(filter, pagination = {}) { - const { User } = this.#uw.models; + const { db } = this.#uw; const offset = pagination.offset ?? 0; const size = clamp( @@ -63,36 +52,66 @@ class Bans { 100, ); - const queryFilter = { - banned: { $ne: null }, - 'banned.expiresAt': { $gt: new Date() }, - }; + let query = db.selectFrom('bans') + .innerJoin('users', 'users.id', 'bans.userID') + .leftJoin('users as mod', 'mod.id', 'bans.moderatorID') + .select([ + 'users.id as users.id', + 'users.username as users.username', + 'users.slug as users.slug', + 'users.createdAt as users.createdAt', + 'users.updatedAt as users.updatedAt', + 'mod.id as mod.id', + 'mod.username as mod.username', + 'mod.slug as mod.slug', + 'mod.createdAt as mod.createdAt', + 'mod.updatedAt as mod.updatedAt', + 'bans.reason', + 'bans.expiresAt', + 'bans.createdAt', + ]) + .where(({ eb, or }) => or([ + eb('expiresAt', 'is', null), + eb('expiresAt', '>', now), + ])); + if (filter) { - Object.assign(queryFilter, { - username: { $regex: RegExp(escapeStringRegExp(filter), 'i') }, - }); + query = query.where('users.username', 'like', filter); } - const total = await User.find().where(queryFilter).countDocuments(); - - const bannedUsers = /** @type {(LeanUser & { banned: LeanBanned })[]} */ ( - await User.find() - .where(queryFilter) - .skip(offset) - .limit(size) - .populate('banned.moderator') - .lean() - ); - - const results = bannedUsers.map((user) => ({ - ...user.banned, - user: omit(user, ['banned']), + const { total } = await db.selectFrom('bans').select(eb => eb.fn.countAll().as('total')).executeTakeFirstOrThrow(); + const { filtered } = await query.select(eb => eb.fn.countAll().as('filtered')).executeTakeFirstOrThrow(); + + query = query.offset(offset).limit(size); + + const bannedUsers = await query.execute(); + const results = bannedUsers.map((row) => ({ + user: { + id: row['users.id'], + username: row['users.username'], + slug: row['users.slug'], + createdAt: row['users.createdAt'], + updatedAt: row['users.updatedAt'], + }, + moderator: row['mod.id'] != null ? { + id: row['mod.id'], + username: row['mod.username'], + slug: row['mod.slug'], + createdAt: row['mod.createdAt'], + updatedAt: row['mod.updatedAt'], + } : null, + reason: row.reason, + duration: row.expiresAt != null + ? Math.floor(row.expiresAt.getTime() / 1_000 - row.createdAt.getTime() / 1_000) * 1_000 + : 0, + expiresAt: row.expiresAt, + createdAt: row.createdAt, })); return new Page(results, { pageSize: pagination ? pagination.limit : undefined, - filtered: total, - total, + filtered: Number(filtered), + total: Number(total), current: { offset, limit: size }, next: pagination ? { offset: offset + size, limit: size } : undefined, previous: offset > 0 @@ -102,67 +121,70 @@ class Bans { } /** - * @param {User} user + * @param {import('../schema.js').User} user * @param {object} options * @param {number} options.duration - * @param {User} options.moderator + * @param {import('../schema.js').User} options.moderator * @param {boolean} [options.permanent] * @param {string} [options.reason] */ async ban(user, { duration, moderator, permanent = false, reason = '', }) { + const { db } = this.#uw; + if (duration <= 0 && !permanent) { throw new Error('Ban duration should be at least 0ms.'); } - const banned = { - duration: permanent ? -1 : duration, - expiresAt: permanent ? undefined : new Date(Date.now() + duration), - moderator: moderator._id, - reason, + const createdAt = new Date(Math.floor(Date.now() / 1_000) * 1_000); + const expiresAt = permanent ? null : new Date(createdAt.getTime() + duration); + const ban = { + userID: user.id, + moderatorID: moderator.id, + createdAt, + expiresAt, + reason: reason || null, }; - user.banned = banned; - await user.save(); - await user.populate('banned.moderator'); + await db.insertInto('bans') + .values(ban) + .executeTakeFirstOrThrow(); this.#uw.publish('user:ban', { userID: user.id, moderatorID: moderator.id, - duration: banned.duration, - expiresAt: banned.expiresAt ? banned.expiresAt.getTime() : null, + duration, + expiresAt: ban.expiresAt ? ban.expiresAt.getTime() : null, permanent, }); - return { - ...banned, - moderator, - }; + return ban; } /** - * @param {string} userID + * @param {import('../schema.js').UserID} userID * @param {object} options - * @param {User} options.moderator + * @param {import('../schema.js').User} options.moderator */ async unban(userID, { moderator }) { - const { users } = this.#uw; + const { db, users } = this.#uw; const user = await users.getUser(userID); if (!user) { throw new UserNotFoundError({ id: userID }); } - if (!user.banned) { + + const result = await db.deleteFrom('bans') + .where('userID', '=', userID) + .executeTakeFirst(); + if (result.numDeletedRows === 0n) { throw new Error(`User "${user.username}" is not banned.`); } - user.banned = undefined; - await user.save(); - this.#uw.publish('user:unban', { - userID: `${user.id}`, - moderatorID: typeof moderator === 'object' ? `${moderator.id}` : moderator, + userID, + moderatorID: moderator.id, }); } } diff --git a/src/plugins/booth.js b/src/plugins/booth.js index 36bbb6a4..a73055d4 100644 --- a/src/plugins/booth.js +++ b/src/plugins/booth.js @@ -1,33 +1,24 @@ -import assert from 'node:assert'; import RedLock from 'redlock'; -import lodash from 'lodash'; import { EmptyPlaylistError, PlaylistItemNotFoundError } from '../errors/index.js'; import routes from '../routes/booth.js'; - -const { omit } = lodash; +import { randomUUID } from 'node:crypto'; +import { jsonb } from '../utils/sqlite.js'; /** + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').HistoryEntryID} HistoryEntryID * @typedef {import('type-fest').JsonObject} JsonObject - * @typedef {import('../models/index.js').User} User - * @typedef {import('../models/index.js').Playlist} Playlist - * @typedef {import('../models/index.js').PlaylistItem} PlaylistItem - * @typedef {import('../models/index.js').HistoryEntry} HistoryEntry - * @typedef {import('../models/History.js').HistoryMedia} HistoryMedia - * @typedef {import('../models/index.js').Media} Media - * @typedef {{ user: User }} PopulateUser - * @typedef {{ playlist: Playlist }} PopulatePlaylist - * @typedef {{ media: Omit & { media: Media } }} PopulateMedia - * @typedef {Omit - * & PopulateUser & PopulatePlaylist & PopulateMedia} PopulatedHistoryEntry + * @typedef {import('../schema.js').User} User + * @typedef {import('../schema.js').Playlist} Playlist + * @typedef {import('../schema.js').PlaylistItem} PlaylistItem + * @typedef {import('../schema.js').HistoryEntry} HistoryEntry + * @typedef {Omit} Media */ const REDIS_ADVANCING = 'booth:advancing'; const REDIS_HISTORY_ID = 'booth:historyID'; const REDIS_CURRENT_DJ_ID = 'booth:currentDJ'; const REDIS_REMOVE_AFTER_CURRENT_PLAY = 'booth:removeAfterCurrentPlay'; -const REDIS_UPVOTES = 'booth:upvotes'; -const REDIS_DOWNVOTES = 'booth:downvotes'; -const REDIS_FAVORITES = 'booth:favorites'; const REMOVE_AFTER_CURRENT_PLAY_SCRIPT = { keys: [REDIS_CURRENT_DJ_ID, REDIS_REMOVE_AFTER_CURRENT_PLAY], @@ -51,18 +42,6 @@ const REMOVE_AFTER_CURRENT_PLAY_SCRIPT = { `, }; -/** - * @param {Playlist} playlist - * @returns {Promise} - */ -async function cyclePlaylist(playlist) { - const item = playlist.media.shift(); - if (item !== undefined) { - playlist.media.push(item); - } - await playlist.save(); -} - class Booth { #uw; @@ -96,8 +75,8 @@ class Booth { if (current && this.#timeout === null) { // Restart the advance timer after a server restart, if a track was // playing before the server restarted. - const duration = (current.media.end - current.media.start) * 1000; - const endTime = Number(current.playedAt) + duration; + const duration = (current.historyEntry.end - current.historyEntry.start) * 1000; + const endTime = current.historyEntry.createdAt.getTime() + duration; if (endTime > Date.now()) { this.#timeout = setTimeout( () => this.#advanceAutomatically(), @@ -126,116 +105,143 @@ class Booth { this.#maybeStop(); } - /** - * @returns {Promise} - */ - async getCurrentEntry() { - const { HistoryEntry } = this.#uw.models; - const historyID = await this.#uw.redis.get(REDIS_HISTORY_ID); + async getCurrentEntry(tx = this.#uw.db) { + const historyID = /** @type {HistoryEntryID} */ (await this.#uw.redis.get(REDIS_HISTORY_ID)); if (!historyID) { return null; } - return HistoryEntry.findById(historyID, '+media.sourceData'); - } - - /** - * Get vote counts for the currently playing media. - * - * @returns {Promise<{ upvotes: string[], downvotes: string[], favorites: string[] }>} - */ - async getCurrentVoteStats() { - const { redis } = this.#uw; - - const results = await redis.pipeline() - .smembers(REDIS_UPVOTES) - .smembers(REDIS_DOWNVOTES) - .smembers(REDIS_FAVORITES) - .exec(); - assert(results); - - const voteStats = { - upvotes: /** @type {string[]} */ (results[0][1]), - downvotes: /** @type {string[]} */ (results[1][1]), - favorites: /** @type {string[]} */ (results[2][1]), - }; - - return voteStats; - } - - /** - * @param {HistoryEntry} entry - */ - async #saveStats(entry) { - const stats = await this.getCurrentVoteStats(); - - Object.assign(entry, stats); - return entry.save(); + const entry = await tx.selectFrom('historyEntries') + .innerJoin('media', 'historyEntries.mediaID', 'media.id') + .innerJoin('users', 'historyEntries.userID', 'users.id') + .select([ + 'historyEntries.id as id', + 'media.id as media.id', + 'media.sourceID as media.sourceID', + 'media.sourceType as media.sourceType', + 'media.sourceData as media.sourceData', + 'media.artist as media.artist', + 'media.title as media.title', + 'media.duration as media.duration', + 'media.thumbnail as media.thumbnail', + 'users.id as users.id', + 'users.username as users.username', + 'users.avatar as users.avatar', + 'users.createdAt as users.createdAt', + 'historyEntries.artist', + 'historyEntries.title', + 'historyEntries.start', + 'historyEntries.end', + 'historyEntries.createdAt', + (eb) => eb.selectFrom('feedback') + .where('historyEntryID', '=', eb.ref('historyEntries.id')) + .where('vote', '=', 1) + .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .as('upvotes'), + (eb) => eb.selectFrom('feedback') + .where('historyEntryID', '=', eb.ref('historyEntries.id')) + .where('vote', '=', -1) + .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .as('downvotes'), + (eb) => eb.selectFrom('feedback') + .where('historyEntryID', '=', eb.ref('historyEntries.id')) + .where('favorite', '=', 1) + .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .as('favorites'), + ]) + .where('historyEntries.id', '=', historyID) + .executeTakeFirst(); + + return entry ? { + media: { + id: entry['media.id'], + artist: entry['media.artist'], + title: entry['media.title'], + duration: entry['media.duration'], + thumbnail: entry['media.thumbnail'], + sourceID: entry['media.sourceID'], + sourceType: entry['media.sourceType'], + sourceData: entry['media.sourceData'] ?? {}, + }, + user: { + id: entry['users.id'], + username: entry['users.username'], + avatar: entry['users.avatar'], + createdAt: entry['users.createdAt'], + }, + historyEntry: { + id: entry.id, + userID: entry['users.id'], + mediaID: entry['media.id'], + artist: entry.artist, + title: entry.title, + start: entry.start, + end: entry.end, + createdAt: entry.createdAt, + }, + upvotes: /** @type {UserID[]} */ (JSON.parse(entry.upvotes)), + downvotes: /** @type {UserID[]} */ (JSON.parse(entry.downvotes)), + favorites: /** @type {UserID[]} */ (JSON.parse(entry.favorites)), + } : null; } /** * @param {{ remove?: boolean }} options - * @returns {Promise} */ - async #getNextDJ(options) { - const { User } = this.#uw.models; - /** @type {string|null} */ - let userID = await this.#uw.redis.lindex('waitlist', 0); + async #getNextDJ(options, tx = this.#uw.db) { + let userID = /** @type {UserID|null} */ (await this.#uw.redis.lindex('waitlist', 0)); if (!userID && !options.remove) { // If the waitlist is empty, the current DJ will play again immediately. - userID = await this.#uw.redis.get(REDIS_CURRENT_DJ_ID); + userID = /** @type {UserID|null} */ (await this.#uw.redis.get(REDIS_CURRENT_DJ_ID)); } if (!userID) { return null; } - return User.findById(userID); + return this.#uw.users.getUser(userID, tx); } /** * @param {{ remove?: boolean }} options - * @returns {Promise} */ async #getNextEntry(options) { - const { HistoryEntry, PlaylistItem } = this.#uw.models; const { playlists } = this.#uw; const user = await this.#getNextDJ(options); - if (!user || !user.activePlaylist) { + if (!user || !user.activePlaylistID) { return null; } - const playlist = await playlists.getUserPlaylist(user, user.activePlaylist); + const playlist = await playlists.getUserPlaylist(user, user.activePlaylistID); if (playlist.size === 0) { throw new EmptyPlaylistError(); } - const playlistItem = await PlaylistItem.findById(playlist.media[0]); + const { playlistItem, media } = await playlists.getPlaylistItemAt(playlist, 0); if (!playlistItem) { - throw new PlaylistItemNotFoundError({ id: playlist.media[0] }); + throw new PlaylistItemNotFoundError(); } - /** @type {PopulatedHistoryEntry} */ - // @ts-expect-error TS2322: `user` and `playlist` are already populated, - // and `media.media` is populated immediately below. - const entry = new HistoryEntry({ + return { user, playlist, - item: playlistItem._id, - media: { - media: playlistItem.media, + playlistItem, + media, + historyEntry: { + id: /** @type {HistoryEntryID} */ (randomUUID()), + userID: user.id, + mediaID: media.id, artist: playlistItem.artist, title: playlistItem.title, start: playlistItem.start, end: playlistItem.end, + /** @type {null | JsonObject} */ + sourceData: null, }, - }); - await entry.populate('media.media'); - - return entry; + }; } /** - * @param {HistoryEntry|null} previous + * @param {UserID|null} previous * @param {{ remove?: boolean }} options */ async #cycleWaitlist(previous, options) { @@ -245,7 +251,7 @@ class Booth { if (previous && !options.remove) { // The previous DJ should only be added to the waitlist again if it was // not empty. If it was empty, the previous DJ is already in the booth. - await this.#uw.redis.rpush('waitlist', previous.user.toString()); + await this.#uw.redis.rpush('waitlist', previous); } } } @@ -255,19 +261,16 @@ class Booth { REDIS_HISTORY_ID, REDIS_CURRENT_DJ_ID, REDIS_REMOVE_AFTER_CURRENT_PLAY, - REDIS_UPVOTES, - REDIS_DOWNVOTES, - REDIS_FAVORITES, ); } /** - * @param {PopulatedHistoryEntry} next + * @param {{ historyEntry: { id: HistoryEntryID }, user: { id: UserID } }} next */ async #update(next) { await this.#uw.redis.multi() - .del(REDIS_UPVOTES, REDIS_DOWNVOTES, REDIS_FAVORITES, REDIS_REMOVE_AFTER_CURRENT_PLAY) - .set(REDIS_HISTORY_ID, next.id) + .del(REDIS_REMOVE_AFTER_CURRENT_PLAY) + .set(REDIS_HISTORY_ID, next.historyEntry.id) .set(REDIS_CURRENT_DJ_ID, next.user.id) .exec(); } @@ -280,13 +283,13 @@ class Booth { } /** - * @param {PopulatedHistoryEntry} entry + * @param {Pick} entry */ #play(entry) { this.#maybeStop(); this.#timeout = setTimeout( () => this.#advanceAutomatically(), - (entry.media.end - entry.media.start) * 1000, + (entry.end - entry.start) * 1000, ); } @@ -298,35 +301,46 @@ class Booth { * a property of the media model for backwards compatibility. * Old clients don't expect `sourceData` directly on a history entry object. * - * @param {PopulateMedia} historyEntry + * @param {{ user: User, media: Media, historyEntry: HistoryEntry }} next */ - - getMediaForPlayback(historyEntry) { - return Object.assign(omit(historyEntry.media, 'sourceData'), { + getMediaForPlayback(next) { + return { + artist: next.historyEntry.artist, + title: next.historyEntry.title, + start: next.historyEntry.start, + end: next.historyEntry.end, media: { - ...historyEntry.media.media.toJSON(), + sourceType: next.media.sourceType, + sourceID: next.media.sourceID, + artist: next.media.artist, + title: next.media.title, + duration: next.media.duration, sourceData: { - ...historyEntry.media.media.sourceData, - ...historyEntry.media.sourceData, + ...next.media.sourceData, + ...next.historyEntry.sourceData, }, }, - }); + }; } /** - * @param {PopulatedHistoryEntry|null} next + * @param {{ + * user: User, + * playlist: Playlist, + * media: Media, + * historyEntry: HistoryEntry + * } | null} next */ async #publishAdvanceComplete(next) { const { waitlist } = this.#uw; - if (next) { + if (next != null) { this.#uw.publish('advance:complete', { - historyID: next.id, + historyID: next.historyEntry.id, userID: next.user.id, playlistID: next.playlist.id, - itemID: next.item.toString(), media: this.getMediaForPlayback(next), - playedAt: next.playedAt.getTime(), + playedAt: next.historyEntry.createdAt.getTime(), }); this.#uw.publish('playlist:cycle', { userID: next.user.id, @@ -339,17 +353,17 @@ class Booth { } /** - * @param {PopulatedHistoryEntry} entry + * @param {{ user: User, media: { sourceID: string, sourceType: string } }} entry */ async #getSourceDataForPlayback(entry) { - const { sourceID, sourceType } = entry.media.media; + const { sourceID, sourceType } = entry.media; const source = this.#uw.source(sourceType); if (source) { this.#logger.trace({ sourceType: source.type, sourceID }, 'running pre-play hook'); /** @type {JsonObject | undefined} */ let sourceData; try { - sourceData = await source.play(entry.user, entry.media.media); + sourceData = await source.play(entry.user, entry.media); this.#logger.trace({ sourceType: source.type, sourceID, sourceData }, 'pre-play hook result'); } catch (error) { this.#logger.error({ sourceType: source.type, sourceID, err: error }, 'pre-play hook failed'); @@ -365,18 +379,24 @@ class Booth { * @prop {boolean} [remove] * @prop {boolean} [publish] * @prop {import('redlock').RedlockAbortSignal} [signal] - * * @param {AdvanceOptions} [opts] - * @returns {Promise} + * @returns {Promise<{ + * historyEntry: HistoryEntry, + * user: User, + * media: Media, + * playlist: Playlist, + * }|null>} */ - async #advanceLocked(opts = {}) { + async #advanceLocked(opts = {}, tx = this.#uw.db) { + const { playlists } = this.#uw; + const publish = opts.publish ?? true; const removeAfterCurrent = (await this.#uw.redis.del(REDIS_REMOVE_AFTER_CURRENT_PLAY)) === 1; const remove = opts.remove || removeAfterCurrent || ( !await this.#uw.waitlist.isCycleEnabled() ); - const previous = await this.getCurrentEntry(); + const previous = await this.getCurrentEntry(tx); let next; try { next = await this.#getNextEntry({ remove }); @@ -385,8 +405,9 @@ class Booth { // and try advancing again. if (err instanceof EmptyPlaylistError) { this.#logger.info('user has empty playlist, skipping on to the next'); - await this.#cycleWaitlist(previous, { remove }); - return this.#advanceLocked({ publish, remove: true }); + const previousDJ = previous != null ? previous.historyEntry.userID : null; + await this.#cycleWaitlist(previousDJ, { remove }); + return this.#advanceLocked({ publish, remove: true }, tx); } throw err; } @@ -396,10 +417,8 @@ class Booth { } if (previous) { - await this.#saveStats(previous); - this.#logger.info({ - id: previous._id, + id: previous.historyEntry.id, artist: previous.media.artist, title: previous.media.title, upvotes: previous.upvotes.length, @@ -408,41 +427,60 @@ class Booth { }, 'previous track stats'); } - if (next) { + let result = null; + if (next != null) { this.#logger.info({ - id: next._id, - artist: next.media.artist, - title: next.media.title, + id: next.playlistItem.id, + artist: next.playlistItem.artist, + title: next.playlistItem.title, }, 'next track'); const sourceData = await this.#getSourceDataForPlayback(next); if (sourceData) { - next.media.sourceData = sourceData; + next.historyEntry.sourceData = sourceData; } - await next.save(); + const historyEntry = await tx.insertInto('historyEntries') + .returningAll() + .values({ + id: next.historyEntry.id, + userID: next.user.id, + mediaID: next.media.id, + artist: next.historyEntry.artist, + title: next.historyEntry.title, + start: next.historyEntry.start, + end: next.historyEntry.end, + sourceData: sourceData != null ? jsonb(sourceData) : null, + }) + .executeTakeFirstOrThrow(); + + result = { + historyEntry, + playlist: next.playlist, + user: next.user, + media: next.media, + }; } else { this.#maybeStop(); } - await this.#cycleWaitlist(previous, { remove }); + await this.#cycleWaitlist(previous != null ? previous.historyEntry.userID : null, { remove }); if (next) { await this.#update(next); - await cyclePlaylist(next.playlist); - this.#play(next); + await playlists.cyclePlaylist(next.playlist, tx); + this.#play(next.historyEntry); } else { await this.clear(); } if (publish !== false) { - await this.#publishAdvanceComplete(next); + await this.#publishAdvanceComplete(result); } - return next; + return result; } /** * @param {AdvanceOptions} [opts] - * @returns {Promise} */ advance(opts = {}) { const result = this.#locker.using( @@ -461,7 +499,7 @@ class Booth { async setRemoveAfterCurrentPlay(user, remove) { const newValue = await this.#uw.redis['uw:removeAfterCurrentPlay']( ...REMOVE_AFTER_CURRENT_PLAY_SCRIPT.keys, - user._id.toString(), + user.id, remove, ); return newValue === 1; diff --git a/src/plugins/chat.js b/src/plugins/chat.js index fbc9b8eb..1cdfd4e9 100644 --- a/src/plugins/chat.js +++ b/src/plugins/chat.js @@ -1,9 +1,10 @@ import { randomUUID } from 'node:crypto'; import routes from '../routes/chat.js'; +import { now } from '../utils/sqlite.js'; /** - * @typedef {import('../models/index.js').User} User - * + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').User} User * @typedef {object} ChatOptions * @prop {number} maxLength */ @@ -34,16 +35,20 @@ class Chat { /** * @param {User} user - * @param {number} duration + * @param {number} duration - Duration in seconds * @param {{ moderator: User }} options */ async mute(user, duration, options) { - await this.#uw.redis.set( - `mute:${user.id}`, - options.moderator.id, - 'PX', - duration, - ); + const { db } = this.#uw; + + const expiresAt = new Date(Date.now() + duration * 1000); + await db.insertInto('mutes') + .values({ + userID: user.id, + moderatorID: options.moderator.id, + expiresAt, + }) + .execute(); this.#uw.publish('chat:mute', { moderatorID: options.moderator.id, @@ -57,7 +62,13 @@ class Chat { * @param {{ moderator: User }} options */ async unmute(user, options) { - await this.#uw.redis.del(`mute:${user.id}`); + const { db } = this.#uw; + + await db.updateTable('mutes') + .where('userID', '=', user.id) + .where('expiresAt', '>', now) + .set({ expiresAt: now, updatedAt: now }) + .execute(); this.#uw.publish('chat:unmute', { moderatorID: options.moderator.id, @@ -67,16 +78,22 @@ class Chat { /** * @param {User} user - * * @private */ - isMuted(user) { - return this.#uw.redis.exists(`mute:${user.id}`); + async isMuted(user) { + const { db } = this.#uw; + + const mute = await db.selectFrom('mutes') + .where('userID', '=', user.id) + .where('expiresAt', '>', now) + .selectAll() + .executeTakeFirst(); + + return mute ?? null; } /** * @param {string} message - * * @private */ truncate(message) { @@ -101,7 +118,7 @@ class Chat { } /** - * @param {{ id: string } | { userID: string } | {}} filter + * @param {{ id: string } | { userID: UserID } | {}} filter * @param {{ moderator: User }} options */ delete(filter, options) { diff --git a/src/plugins/configStore.js b/src/plugins/configStore.js index 44db89d2..125e2fba 100644 --- a/src/plugins/configStore.js +++ b/src/plugins/configStore.js @@ -2,14 +2,19 @@ import fs from 'node:fs'; import EventEmitter from 'node:events'; import Ajv from 'ajv/dist/2019.js'; import formats from 'ajv-formats'; -import lodash from 'lodash'; import jsonMergePatch from 'json-merge-patch'; import sjson from 'secure-json-parse'; import ValidationError from '../errors/ValidationError.js'; +import { sql } from 'kysely'; +import { jsonb } from '../utils/sqlite.js'; -const { omit } = lodash; +/** + * @typedef {import('type-fest').JsonObject} JsonObject + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').User} User + */ -/** @typedef {import('../models/index.js').User} User */ +const CONFIG_UPDATE_MESSAGE = 'configStore:update'; /** * Extensible configuration store. @@ -27,7 +32,7 @@ class ConfigStore { #ajv; - #emitter; + #emitter = new EventEmitter(); /** @type {Map>} */ #validators = new Map(); @@ -53,13 +58,11 @@ class ConfigStore { fs.readFileSync(new URL('../schemas/definitions.json', import.meta.url), 'utf8'), )); - this.#emitter = new EventEmitter(); - this.#subscriber.subscribe('uwave').catch((error) => { - this.#logger.error(error); - }); this.#subscriber.on('message', (_channel, command) => { this.#onServerMessage(command); }); + + uw.use(async () => this.#subscriber.subscribe('uwave')); } /** @@ -77,10 +80,12 @@ class ConfigStore { return; } const { command, data } = json; - if (command !== 'configStore:update') { + if (command !== CONFIG_UPDATE_MESSAGE) { return; } + this.#logger.trace({ command, data }, 'handle config update'); + try { const updatedSettings = await this.get(data.key); this.#emitter.emit(data.key, updatedSettings, data.user, data.patch); @@ -90,9 +95,9 @@ class ConfigStore { } /** - * @template {object} TSettings + * @template {JsonObject} TSettings * @param {string} key - * @param {(settings: TSettings, user: string|null, patch: Partial) => void} listener + * @param {(settings: TSettings, user: UserID|null, patch: Partial) => void} listener */ subscribe(key, listener) { this.#emitter.on(key, listener); @@ -100,34 +105,46 @@ class ConfigStore { } /** - * @param {string} key - * @param {object} values - * @returns {Promise} The old values. + * @param {string} name + * @param {JsonObject} value + * @returns {Promise} The old values. */ - async #save(key, values) { - const { Config } = this.#uw.models; + async #save(name, value) { + const { db } = this.#uw; - const previousValues = await Config.findByIdAndUpdate( - key, - { _id: key, ...values }, - { upsert: true }, - ); + const previous = await db.transaction().execute(async (tx) => { + const row = await tx.selectFrom('configuration') + .select(sql`json(value)`.as('value')) + .where('name', '=', name) + .executeTakeFirst(); + + await tx.insertInto('configuration') + .values({ name, value: jsonb(value) }) + .onConflict((oc) => oc.column('name').doUpdateSet({ value: jsonb(value) })) + .execute(); - return omit(previousValues, '_id'); + return row?.value != null ? JSON.parse(/** @type {string} */ (row.value)) : null; + }); + + return previous; } /** * @param {string} key - * @returns {Promise} + * @returns {Promise} */ async #load(key) { - const { Config } = this.#uw.models; - - const model = await Config.findById(key); - if (!model) return null; + const { db } = this.#uw; + + const row = await db.selectFrom('configuration') + .select(sql`json(value)`.as('value')) + .where('name', '=', key) + .executeTakeFirst(); + if (!row) { + return null; + } - const doc = model.toJSON(); - return omit(doc, '_id'); + return JSON.parse(/** @type {string} */ (row.value)); } /** @@ -146,13 +163,16 @@ class ConfigStore { * Get the current settings for a config group. * * @param {string} key - * @returns {Promise} - `undefined` if the config group named `key` does not + * @returns {Promise} + * `undefined` if the config group named `key` does not * exist. An object containing current settings otherwise. * @public */ async get(key) { const validate = this.#validators.get(key); - if (!validate) return undefined; + if (!validate) { + return undefined; + } const config = (await this.#load(key)) ?? {}; // Allowed to fail--just fills in defaults @@ -167,7 +187,7 @@ class ConfigStore { * Rejects if the settings do not follow the schema for the config group. * * @param {string} key - * @param {object} settings + * @param {JsonObject} settings * @param {{ user?: User }} [options] * @public */ @@ -176,6 +196,7 @@ class ConfigStore { const validate = this.#validators.get(key); if (validate) { if (!validate(settings)) { + this.#logger.trace({ key, errors: validate.errors }, 'config validation error'); throw new ValidationError(validate.errors, this.#ajv); } } @@ -183,7 +204,8 @@ class ConfigStore { const oldSettings = await this.#save(key, settings); const patch = jsonMergePatch.generate(oldSettings, settings) ?? Object.create(null); - this.#uw.publish('configStore:update', { + this.#logger.trace({ key, patch }, 'fire config update'); + await this.#uw.publish(CONFIG_UPDATE_MESSAGE, { key, user: user ? user.id : null, patch, @@ -193,20 +215,27 @@ class ConfigStore { /** * Get *all* settings. * - * @returns {Promise<{ [key: string]: object }>} + * @returns {Promise<{ [key: string]: JsonObject }>} */ async getAllConfig() { - const { Config } = this.#uw.models; + const { db } = this.#uw; + + const results = await db.selectFrom('configuration') + .select(['name', sql`json(value)`.as('value')]) + .execute(); - const all = await Config.find(); - const object = Object.create(null); + const configs = Object.create(null); for (const [key, validate] of this.#validators.entries()) { - const model = all.find((m) => m._id === key); - object[key] = model ? model.toJSON() : {}; - delete object[key]._id; - validate(object[key]); + const row = results.find((m) => m.name === key); + if (row) { + const value = JSON.parse(/** @type {string} */ (row.value)); + validate(value); + configs[key] = value; + } else { + configs[key] = {}; + } } - return object; + return configs; } /** diff --git a/src/plugins/emotes.js b/src/plugins/emotes.js index 820d1622..fe222051 100644 --- a/src/plugins/emotes.js +++ b/src/plugins/emotes.js @@ -21,7 +21,6 @@ const schema = JSON.parse( * channels: string[], * }} TwitchSettings * @typedef {{ twitch: TwitchSettings }} EmotesSettings - * * @typedef {{ id: string, code: string, imageType: string, animated: boolean }} BTTVEmote * @typedef {{ id: string, name: string }} FFZEmote * @typedef {{ emoticons: FFZEmote[] }} FFZEmoteSet @@ -76,9 +75,9 @@ async function fetchJSON(url) { } /** -* @param {BTTVEmote} bttv -* @returns {Emote} -*/ + * @param {BTTVEmote} bttv + * @returns {Emote} + */ function fromBTTVEmote(bttv) { return { // The `replace` is basically just for :tf: … @@ -131,9 +130,9 @@ async function getBTTVEmotes(channels) { } /** -* @param {FFZEmote} emote -* @returns {Emote} -*/ + * @param {FFZEmote} emote + * @returns {Emote} + */ function fromFFZEmote(emote) { return { name: emote.name, diff --git a/src/plugins/history.js b/src/plugins/history.js index a0586441..8d3ceb08 100644 --- a/src/plugins/history.js +++ b/src/plugins/history.js @@ -6,16 +6,117 @@ const { clamp } = lodash; const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 100; +/** @typedef {import('../schema.js').Database} Database */ + +const historyEntrySelection = /** @type {const} */ ([ + 'historyEntries.id', + 'historyEntries.artist', + 'historyEntries.title', + 'historyEntries.start', + 'historyEntries.end', + 'historyEntries.sourceData', + 'historyEntries.createdAt as playedAt', + 'users.id as user.id', + 'users.username as user.username', + 'users.slug as user.slug', + 'users.createdAt as user.createdAt', + 'media.id as media.id', + 'media.artist as media.artist', + 'media.title as media.title', + 'media.thumbnail as media.thumbnail', + 'media.duration as media.duration', + 'media.sourceType as media.sourceType', + 'media.sourceID as media.sourceID', + 'media.sourceData as media.sourceData', + /** @param {import('kysely').ExpressionBuilder} eb */ + (eb) => eb.selectFrom('feedback') + .where('historyEntryID', '=', eb.ref('historyEntries.id')) + .where('vote', '=', 1) + .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .as('upvotes'), + /** @param {import('kysely').ExpressionBuilder} eb */ + (eb) => eb.selectFrom('feedback') + .where('historyEntryID', '=', eb.ref('historyEntries.id')) + .where('vote', '=', -1) + .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .as('downvotes'), + /** @param {import('kysely').ExpressionBuilder} eb */ + (eb) => eb.selectFrom('feedback') + .where('historyEntryID', '=', eb.ref('historyEntries.id')) + .where('favorite', '=', 1) + .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .as('favorites'), +]); + +/** + * @param {{ + * id: HistoryEntryID, + * artist: string, + * title: string, + * start: number, + * end: number, + * sourceData: import('type-fest').JsonObject | null, + * playedAt: Date, + * 'user.id': UserID, + * 'user.username': string, + * 'user.slug': string, + * 'user.createdAt': Date, + * 'media.id': MediaID, + * 'media.sourceType': string, + * 'media.sourceID': string, + * 'media.sourceData': import('type-fest').JsonObject | null, + * 'media.artist': string, + * 'media.title': string, + * 'media.thumbnail': string, + * 'media.duration': number, + * upvotes: string, + * downvotes: string, + * favorites: string, + * }} row + */ +function historyEntryFromRow(row) { + return { + _id: row.id, + playedAt: row.playedAt, + user: { + _id: row['user.id'], + username: row['user.username'], + slug: row['user.slug'], + createdAt: row['user.createdAt'], + }, + media: { + artist: row.artist, + title: row.title, + start: row.start, + end: row.end, + sourceData: row.sourceData, + media: { + _id: row['media.id'], + sourceType: row['media.sourceType'], + sourceID: row['media.sourceID'], + sourceData: row['media.sourceData'], + artist: row['media.artist'], + title: row['media.title'], + thumbnail: row['media.thumbnail'], + duration: row['media.duration'], + }, + }, + /** @type {UserID[]} */ + upvotes: JSON.parse(row.upvotes), + /** @type {UserID[]} */ + downvotes: JSON.parse(row.downvotes), + /** @type {UserID[]} */ + favorites: JSON.parse(row.favorites), + }; +} + /** - * @typedef {import('../models/History.js').HistoryMedia} HistoryMedia - * @typedef {import('../models/index.js').HistoryEntry} HistoryEntry - * @typedef {import('../models/index.js').User} User - * @typedef {import('../models/index.js').Media} Media - * @typedef {{ media: Media }} PopulateMedia - * @typedef {{ user: User }} PopulateUser - * @typedef {HistoryMedia & PopulateMedia} PopulatedHistoryMedia - * @typedef {{ media: PopulatedHistoryMedia }} PopulateHistoryMedia - * @typedef {HistoryEntry & PopulateUser & PopulateHistoryMedia} PopulatedHistoryEntry + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').MediaID} MediaID + * @typedef {import('../schema.js').HistoryEntryID} HistoryEntryID + * @typedef {import('../schema.js').HistoryEntry} HistoryEntry + * @typedef {import('../schema.js').User} User + * @typedef {import('../schema.js').Media} Media */ class HistoryRepository { @@ -28,13 +129,26 @@ class HistoryRepository { this.#uw = uw; } + /** @param {HistoryEntryID} id */ + async getEntry(id) { + const { db } = this.#uw; + + const row = await db.selectFrom('historyEntries') + .innerJoin('users', 'historyEntries.userID', 'users.id') + .innerJoin('media', 'historyEntries.mediaID', 'media.id') + .select(historyEntrySelection) + .where('historyEntries.id', '=', id) + .executeTakeFirst(); + + return row != null ? historyEntryFromRow(row) : null; + } + /** - * @param {object|null} filter * @param {{ offset?: number, limit?: number }} [pagination] - * @returns {Promise>} + * @param {{ user?: UserID }} [options] */ - async getHistory(filter, pagination = {}) { - const { HistoryEntry } = this.#uw.models; + async getHistory(pagination = {}, options = {}) { + const { db } = this.#uw; const offset = pagination.offset ?? 0; const limit = clamp( @@ -43,47 +157,27 @@ class HistoryRepository { MAX_PAGE_SIZE, ); - const total = filter != null - ? await HistoryEntry.where(filter).countDocuments() - : await HistoryEntry.estimatedDocumentCount(); - /** @type {import('mongoose').PipelineStage[]} */ - const aggregate = []; - if (filter != null) { - aggregate.push({ $match: filter }); + let query = db.selectFrom('historyEntries'); + if (options.user) { + query = query.where('userID', '=', options.user); } - aggregate.push( - { $sort: { playedAt: -1 } }, - { $skip: offset }, - { $limit: limit }, - { - $lookup: { - from: 'media', - localField: 'media.media', - foreignField: '_id', - as: 'media.media', - }, - }, - { $unwind: '$media.media' }, - { - $lookup: { - from: 'users', - localField: 'user', - foreignField: '_id', - as: 'user', - }, - }, - { $unwind: '$user' }, - { $project: { __v: 0, 'media.media.__v': 0, 'user.__v': 0 } }, - ); - const query = HistoryEntry.aggregate(aggregate); - /** @type {PopulatedHistoryEntry[]} */ - const results = /** @type {any} */ (await query); + const total = await query.select((eb) => eb.fn.countAll().as('count')).executeTakeFirstOrThrow(); + const rows = await query + .innerJoin('users', 'historyEntries.userID', 'users.id') + .innerJoin('media', 'historyEntries.mediaID', 'media.id') + .select(historyEntrySelection) + .orderBy('historyEntries.createdAt', 'desc') + .offset(offset) + .limit(limit) + .execute(); + + const historyEntries = rows.map(historyEntryFromRow); - return new Page(results, { + return new Page(historyEntries, { pageSize: pagination ? pagination.limit : undefined, - filtered: total, - total, + filtered: Number(total), + total: Number(total), current: { offset, limit }, next: pagination ? { offset: offset + limit, limit } : undefined, previous: offset > 0 @@ -96,7 +190,7 @@ class HistoryRepository { * @param {{ offset?: number, limit?: number }} [pagination] */ getRoomHistory(pagination = {}) { - return this.getHistory(null, pagination); + return this.getHistory(pagination, {}); } /** @@ -104,7 +198,7 @@ class HistoryRepository { * @param {{ offset?: number, limit?: number }} [pagination] */ getUserHistory(user, pagination = {}) { - return this.getHistory({ user: user._id }, pagination); + return this.getHistory(pagination, { user: user.id }); } } diff --git a/src/plugins/migrations.js b/src/plugins/migrations.js index 4cf2417c..0c95a5f2 100644 --- a/src/plugins/migrations.js +++ b/src/plugins/migrations.js @@ -6,43 +6,37 @@ import { Umzug } from 'umzug'; * @typedef {import('../Uwave.js').default} Uwave */ -/** - * Custom MongoDBStorage based on Mongoose and with timestamps. - */ -const mongooseStorage = { +const kyselyStorage = { /** * @param {import('umzug').MigrationParams} params */ async logMigration({ name, context: uw }) { - const { Migration } = uw.models; + const { db } = uw; - await Migration.create({ - migrationName: name, - }); + await db.insertInto('migrations') + .values({ name }) + .execute(); }, /** * @param {import('umzug').MigrationParams} params */ async unlogMigration({ name, context: uw }) { - const { Migration } = uw.models; + const { db } = uw; - await Migration.deleteOne({ - migrationName: name, - }); + await db.deleteFrom('migrations') + .where('name', '=', name) + .execute(); }, /** * @param {{ context: Uwave }} params */ async executed({ context: uw }) { - const { Migration } = uw.models; + const { db } = uw; + const rows = await db.selectFrom('migrations').select(['name']).execute(); - /** @type {{ migrationName: string }[]} */ - const documents = await Migration.find({}) - .select({ migrationName: 1 }) - .lean(); - return documents.map((doc) => doc.migrationName); + return rows.map((row) => row.name); }, }; @@ -55,14 +49,20 @@ const mongooseStorage = { * @param {Uwave} uw */ async function migrationsPlugin(uw) { + const { schema } = uw.db; const redLock = new RedLock([uw.redis]); + schema.createTable('migrations') + .ifNotExists() + .addColumn('name', 'text', (col) => col.notNull().unique()) + .execute(); + /** @type {Migrate} */ async function migrate(migrations) { const migrator = new Umzug({ migrations, context: uw, - storage: mongooseStorage, + storage: kyselyStorage, logger: uw.logger.child({ ns: 'uwave:migrations' }), }); @@ -72,9 +72,14 @@ async function migrationsPlugin(uw) { } uw.migrate = migrate; - await uw.migrate({ - glob: ['*.cjs', { cwd: fileURLToPath(new URL('../migrations', import.meta.url)) }], - }); + try { + await uw.migrate({ + glob: ['*.cjs', { cwd: fileURLToPath(new URL('../migrations', import.meta.url)) }], + }); + } catch (err) { + if (err.migration) err.migration.context = null; + throw err; + } } export default migrationsPlugin; diff --git a/src/plugins/motd.js b/src/plugins/motd.js index cdfb90a5..98eb5eb5 100644 --- a/src/plugins/motd.js +++ b/src/plugins/motd.js @@ -1,5 +1,7 @@ import routes from '../routes/motd.js'; +const CONFIG_MOTD = 'u-wave:motd'; + class MOTD { #uw; @@ -8,13 +10,24 @@ class MOTD { */ constructor(uw) { this.#uw = uw; + + uw.config.register(CONFIG_MOTD, { + type: 'object', + properties: { + text: { type: 'string', nullable: true }, + }, + }); } /** * @returns {Promise} */ - get() { - return this.#uw.redis.get('motd'); + async get() { + const config = /** @type {{ text?: string | null } | null} */ ( + await this.#uw.config.get(CONFIG_MOTD) + ); + + return config?.text ?? null; } /** @@ -22,11 +35,7 @@ class MOTD { * @returns {Promise} */ async set(motd) { - if (motd) { - await this.#uw.redis.set('motd', motd); - } else { - await this.#uw.redis.del('motd'); - } + await this.#uw.config.set(CONFIG_MOTD, { text: motd }); } } diff --git a/src/plugins/passport.js b/src/plugins/passport.js index 7c35f192..80519757 100644 --- a/src/plugins/passport.js +++ b/src/plugins/passport.js @@ -10,8 +10,8 @@ const schema = JSON.parse( ); /** - * @typedef {import('../models/User.js').User} User - * + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').User} User * @typedef {{ * callbackURL?: string, * } & ({ @@ -21,7 +21,6 @@ const schema = JSON.parse( * clientID: string, * clientSecret: string, * })} GoogleOptions - * * @typedef {object} SocialAuthSettings * @prop {GoogleOptions} google */ @@ -43,17 +42,17 @@ class PassportPlugin extends Passport { /** * @param {Express.User} user - * @returns {Promise} + * @returns {Promise} */ function serializeUser(user) { - /** @type {string} */ + /** @type {UserID} */ // @ts-expect-error `user` is actually an instance of the User model // but we can't express that const userID = user.id; return Promise.resolve(userID); } /** - * @param {string} id + * @param {UserID} id * @returns {Promise} */ function deserializeUser(id) { @@ -77,11 +76,11 @@ class PassportPlugin extends Passport { passwordField: 'password', session: false, }, callbackify(localLogin))); - this.use('jwt', new JWTStrategy(options.secret, async (user) => { + this.use('jwt', new JWTStrategy(options.secret, async (claim) => { try { - return await uw.users.getUser(user.id); + return await uw.users.getUser(claim.id); } catch (err) { - this.#logger.warn({ err, user }, 'could not load user from JWT'); + this.#logger.warn({ err, claim }, 'could not load user from JWT'); return null; } })); @@ -109,8 +108,8 @@ class PassportPlugin extends Passport { } /** - * @param {string} accessToken - * @param {string} refreshToken + * @param {string} accessToken Not used as we do not need to access the account. + * @param {string} refreshToken Not used as we do not need to access the account. * @param {import('passport').Profile} profile * @returns {Promise} * @private diff --git a/src/plugins/playlists.js b/src/plugins/playlists.js index 78d8e3fa..a7639396 100644 --- a/src/plugins/playlists.js +++ b/src/plugins/playlists.js @@ -1,49 +1,40 @@ -import lodash from 'lodash'; -import escapeStringRegExp from 'escape-string-regexp'; +import ObjectGroupBy from 'object.groupby'; import { PlaylistNotFoundError, - PlaylistItemNotFoundError, ItemNotInPlaylistError, MediaNotFoundError, UserNotFoundError, } from '../errors/index.js'; import Page from '../Page.js'; import routes from '../routes/playlists.js'; - -const { groupBy, shuffle } = lodash; +import { randomUUID } from 'node:crypto'; +import { sql } from 'kysely'; +import { + arrayCycle, jsonb, jsonEach, jsonLength, arrayShuffle as arrayShuffle, +} from '../utils/sqlite.js'; +import Multimap from '../utils/Multimap.js'; /** - * @typedef {import('mongoose').PipelineStage} PipelineStage - * @typedef {import('mongoose').PipelineStage.Facet['$facet'][string]} FacetPipelineStage - * @typedef {import('mongodb').ObjectId} ObjectId - * @typedef {import('../models/index.js').User} User - * @typedef {import('../models/index.js').Playlist} Playlist - * @typedef {import('../models/Playlist.js').LeanPlaylist} LeanPlaylist - * @typedef {import('../models/index.js').PlaylistItem} PlaylistItem - * @typedef {import('../models/index.js').Media} Media - * @typedef {{ media: Media }} PopulateMedia + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').MediaID} MediaID + * @typedef {import('../schema.js').PlaylistID} PlaylistID + * @typedef {import('../schema.js').PlaylistItemID} PlaylistItemID + * @typedef {import('../schema.js').User} User + * @typedef {import('../schema.js').Playlist} Playlist + * @typedef {import('../schema.js').PlaylistItem} PlaylistItem + * @typedef {import('../schema.js').Media} Media */ /** * @typedef {object} PlaylistItemDesc * @prop {string} sourceType - * @prop {string|number} sourceID + * @prop {string} sourceID * @prop {string} [artist] * @prop {string} [title] * @prop {number} [start] * @prop {number} [end] */ -/** - * @param {PlaylistItemDesc} item - * @returns {boolean} - */ -function isValidPlaylistItem(item) { - return typeof item === 'object' - && typeof item.sourceType === 'string' - && (typeof item.sourceID === 'string' || typeof item.sourceID === 'number'); -} - /** * Calculate valid start/end times for a playlist item. * @@ -65,19 +56,102 @@ function getStartEnd(item, media) { return { start, end }; } +const playlistItemSelection = /** @type {const} */ ([ + 'playlistItems.id as id', + 'media.id as media.id', + 'media.sourceID as media.sourceID', + 'media.sourceType as media.sourceType', + 'media.sourceData as media.sourceData', + 'media.artist as media.artist', + 'media.title as media.title', + 'media.duration as media.duration', + 'media.thumbnail as media.thumbnail', + 'playlistItems.artist', + 'playlistItems.title', + 'playlistItems.start', + 'playlistItems.end', + 'playlistItems.createdAt', + 'playlistItems.updatedAt', +]); + /** - * @param {PlaylistItemDesc} itemProps - * @param {Media} media + * @param {{ + * id: PlaylistItemID, + * 'media.id': MediaID, + * 'media.sourceID': string, + * 'media.sourceType': string, + * 'media.sourceData': import('type-fest').JsonObject | null, + * 'media.artist': string, + * 'media.title': string, + * 'media.duration': number, + * 'media.thumbnail': string, + * artist: string, + * title: string, + * start: number, + * end: number, + * }} raw + */ +function playlistItemFromSelection(raw) { + return { + _id: raw.id, + artist: raw.artist, + title: raw.title, + start: raw.start, + end: raw.end, + media: { + _id: raw['media.id'], + artist: raw['media.artist'], + title: raw['media.title'], + duration: raw['media.duration'], + thumbnail: raw['media.thumbnail'], + sourceID: raw['media.sourceID'], + sourceType: raw['media.sourceType'], + sourceData: raw['media.sourceData'], + }, + }; +} + +/** + * @param {{ + * id: PlaylistItemID, + * 'media.id': MediaID, + * 'media.sourceID': string, + * 'media.sourceType': string, + * 'media.sourceData': import('type-fest').JsonObject | null, + * 'media.artist': string, + * 'media.title': string, + * 'media.duration': number, + * 'media.thumbnail': string, + * artist: string, + * title: string, + * start: number, + * end: number, + * createdAt: Date, + * updatedAt: Date, + * }} raw */ -function toPlaylistItem(itemProps, media) { - const { artist, title } = itemProps; - const { start, end } = getStartEnd(itemProps, media); +function playlistItemFromSelectionNew(raw) { return { - media, - artist: artist ?? media.artist, - title: title ?? media.title, - start, - end, + playlistItem: { + id: raw.id, + mediaID: raw['media.id'], + artist: raw.artist, + title: raw.title, + start: raw.start, + end: raw.end, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + }, + media: { + id: raw['media.id'], + artist: raw['media.artist'], + title: raw['media.title'], + duration: raw['media.duration'], + thumbnail: raw['media.thumbnail'], + sourceID: raw['media.sourceID'], + sourceType: raw['media.sourceType'], + sourceData: raw['media.sourceData'], + }, }; } @@ -94,217 +168,249 @@ class PlaylistsRepository { this.#logger = uw.logger.child({ ns: 'uwave:playlists' }); } - /** - * @param {ObjectId} id - * @return {Promise} - */ - async getPlaylist(id) { - const { Playlist } = this.#uw.models; - if (id instanceof Playlist) { - return id; - } - const playlist = await Playlist.findById(id); - if (!playlist) { - throw new PlaylistNotFoundError({ id }); - } - return playlist; - } - - /** - * @param {ObjectId} id - * @return {Promise} - */ - async getMedia(id) { - const { Media } = this.#uw.models; - if (id instanceof Media) { - return id; - } - const media = await Media.findById(id); - if (!media) { - throw new MediaNotFoundError({ id }); - } - return media; - } - /** * @param {User} user - * @param {ObjectId} id - * @returns {Promise} + * @param {PlaylistID} id */ - async getUserPlaylist(user, id) { - const { Playlist } = this.#uw.models; - const playlist = await Playlist.findOne({ _id: id, author: user._id }); + async getUserPlaylist(user, id, tx = this.#uw.db) { + const playlist = await tx.selectFrom('playlists') + .where('userID', '=', user.id) + .where('id', '=', id) + .select([ + 'id', + 'userID', + 'name', + 'createdAt', + 'updatedAt', + (eb) => jsonLength(eb.ref('items')).as('size'), + ]) + .executeTakeFirst(); + if (!playlist) { throw new PlaylistNotFoundError({ id }); } - return playlist; + return { + ...playlist, + size: Number(playlist.size), + }; } /** * @param {User} user * @param {{ name: string }} options - * @returns {Promise} */ - async createPlaylist(user, { name }) { - const { Playlist } = this.#uw.models; - - const playlist = await Playlist.create({ - name, - author: user._id, - }); - + async createPlaylist(user, { name }, tx = this.#uw.db) { + const id = /** @type {PlaylistID} */ (randomUUID()); + + const playlist = await tx.insertInto('playlists') + .values({ + id, + name, + userID: user.id, + items: jsonb([]), + }) + .returning([ + 'id', + 'userID', + 'name', + (eb) => jsonLength(eb.ref('items')).as('size'), + 'createdAt', + 'updatedAt', + ]) + .executeTakeFirstOrThrow(); + + let active = false; // If this is the user's first playlist, immediately activate it. - if (user.activePlaylist == null) { + if (user.activePlaylistID == null) { this.#logger.info({ userId: user.id, playlistId: playlist.id }, 'activating first playlist'); - user.activePlaylist = playlist._id; - await user.save(); + await tx.updateTable('users') + .where('users.id', '=', user.id) + .set({ activePlaylistID: playlist.id }) + .execute(); + active = true; } - return playlist; + return { playlist, active }; } /** * @param {User} user - * @returns {Promise} */ - async getUserPlaylists(user) { - const { Playlist } = this.#uw.models; - const userID = typeof user === 'object' ? user.id : user; - const playlists = await Playlist.where('author', userID).lean(); - return playlists; + async getUserPlaylists(user, tx = this.#uw.db) { + const playlists = await tx.selectFrom('playlists') + .where('userID', '=', user.id) + .select([ + 'id', + 'userID', + 'name', + (eb) => jsonLength(eb.ref('items')).as('size'), + 'createdAt', + 'updatedAt', + ]) + .execute(); + + return playlists.map((playlist) => { + return { ...playlist, size: Number(playlist.size) }; + }); } /** * @param {Playlist} playlist - * @param {object} patch - * @returns {Promise} + * @param {Partial>} patch */ - - async updatePlaylist(playlist, patch = {}) { - Object.assign(playlist, patch); - await playlist.save(); - return playlist; + async updatePlaylist(playlist, patch = {}, tx = this.#uw.db) { + const updatedPlaylist = await tx.updateTable('playlists') + .where('id', '=', playlist.id) + .set(patch) + .returning([ + 'id', + 'userID', + 'name', + (eb) => jsonLength(eb.ref('items')).as('size'), + 'createdAt', + 'updatedAt', + ]) + .executeTakeFirstOrThrow(); + + return updatedPlaylist; } /** + * "Cycle" the playlist, moving its first item to last. + * * @param {Playlist} playlist - * @returns {Promise} */ - - async shufflePlaylist(playlist) { - playlist.media = shuffle(playlist.media); - await playlist.save(); - return playlist; + async cyclePlaylist(playlist, tx = this.#uw.db) { + await tx.updateTable('playlists') + .where('id', '=', playlist.id) + .set('items', (eb) => arrayCycle(eb.ref('items'))) + .execute(); } /** * @param {Playlist} playlist - * @returns {Promise} */ - - async deletePlaylist(playlist) { - await playlist.deleteOne(); + async shufflePlaylist(playlist, tx = this.#uw.db) { + await tx.updateTable('playlists') + .where('id', '=', playlist.id) + .set('items', (eb) => arrayShuffle(eb.ref('items'))) + .execute(); } /** * @param {Playlist} playlist - * @param {ObjectId} itemID - * @returns {Promise} */ - async getPlaylistItem(playlist, itemID) { - const { PlaylistItem } = this.#uw.models; - - const playlistItemID = playlist.media.find((id) => id.equals(itemID)); + async deletePlaylist(playlist, tx = this.#uw.db) { + await tx.deleteFrom('playlists') + .where('id', '=', playlist.id) + .execute(); + } - if (!playlistItemID) { - throw new ItemNotInPlaylistError({ playlistID: playlist._id, itemID }); + /** + * @param {Playlist} playlist + * @param {PlaylistItemID} itemID + */ + async getPlaylistItem(playlist, itemID, tx = this.#uw.db) { + const raw = await tx.selectFrom('playlistItems') + .where('playlistItems.id', '=', itemID) + .where('playlistItems.playlistID', '=', playlist.id) + .innerJoin('media', 'media.id', 'playlistItems.mediaID') + .select(playlistItemSelection) + .executeTakeFirst(); + + if (raw == null) { + throw new ItemNotInPlaylistError({ playlistID: playlist.id, itemID }); } - const item = await PlaylistItem.findById(playlistItemID); - if (!item) { - throw new PlaylistItemNotFoundError({ id: playlistItemID }); - } + return playlistItemFromSelectionNew(raw); + } - if (!item.populated('media')) { - await item.populate('media'); + /** + * @param {Playlist} playlist + * @param {number} order + */ + async getPlaylistItemAt(playlist, order, tx = this.#uw.db) { + const raw = await tx.selectFrom('playlistItems') + .where('playlistItems.playlistID', '=', playlist.id) + .where('playlistItems.id', '=', (eb) => { + /** @type {import('kysely').RawBuilder} */ + // items->>order doesn't work for some reason, not sure why + const item = sql`json_extract(items, ${`$[${order}]`})`; + return eb.selectFrom('playlists') + .select(item.as('playlistItemID')) + .where('id', '=', playlist.id); + }) + .innerJoin('media', 'media.id', 'playlistItems.mediaID') + .select(playlistItemSelection) + .executeTakeFirst(); + + if (raw == null) { + throw new ItemNotInPlaylistError({ playlistID: playlist.id }); } - // @ts-expect-error TS2322: The types of `media` are incompatible, but we just populated it, - // typescript just doesn't know about that. - return item; + return playlistItemFromSelectionNew(raw); } /** - * @param {Playlist} playlist + * @param {{ id: PlaylistID }} playlist * @param {string|undefined} filter * @param {{ offset: number, limit: number }} pagination - * @returns {Promise>} */ - async getPlaylistItems(playlist, filter, pagination) { - const { Playlist } = this.#uw.models; - - /** @type {PipelineStage[]} */ - const aggregate = [ - // find the playlist - { $match: { _id: playlist._id } }, - { $limit: 1 }, - // find the items - { $project: { _id: 0, media: 1 } }, - { $unwind: '$media' }, - { - $lookup: { - from: 'playlistitems', localField: 'media', foreignField: '_id', as: 'item', - }, - }, - // return only the items - { $unwind: '$item' }, // just one each - { $replaceRoot: { newRoot: '$item' } }, - ]; - - if (filter) { - const rx = new RegExp(escapeStringRegExp(filter), 'i'); - aggregate.push({ - $match: { - $or: [{ artist: rx }, { title: rx }], - }, - }); + async getPlaylistItems(playlist, filter, pagination, tx = this.#uw.db) { + let query = tx.selectFrom('playlists') + .innerJoin( + (eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'), + (join) => join, + ) + .innerJoin('playlistItems', (join) => join.on((eb) => eb( + eb.ref('playlistItemIDs.value'), + '=', + eb.ref('playlistItems.id'), + ))) + .innerJoin('media', 'playlistItems.mediaID', 'media.id') + .where('playlists.id', '=', playlist.id) + .select(playlistItemSelection); + if (filter != null) { + query = query.where((eb) => eb.or([ + eb('playlistItems.artist', 'like', `%${filter}%`), + eb('playlistItems.title', 'like', `%${filter}%`), + ])); } - /** @type {FacetPipelineStage} */ - const aggregateCount = [ - { $count: 'filtered' }, - ]; - /** @type {FacetPipelineStage} */ - const aggregateItems = [ - { $skip: pagination.offset }, - { $limit: pagination.limit }, - ]; - - // look up the media items after this is all filtered down - aggregateItems.push( - { - $lookup: { - from: 'media', localField: 'media', foreignField: '_id', as: 'media', - }, - }, - { $unwind: '$media' }, // is always 1 item, is there a better way than $unwind? - ); - - aggregate.push({ - $facet: { - count: aggregateCount, - items: aggregateItems, - }, - }); - - const [{ count, items }] = await Playlist.aggregate(aggregate); - - // `items` is the same shape as a PlaylistItem instance! - return new Page(items, { + query = query + .offset(pagination.offset) + .limit(pagination.limit); + + const totalQuery = tx.selectFrom('playlists') + .select((eb) => jsonLength(eb.ref('items')).as('count')) + .where('id', '=', playlist.id) + .executeTakeFirstOrThrow(); + + const filteredQuery = filter == null ? totalQuery : tx.selectFrom('playlistItems') + .select((eb) => eb.fn.countAll().as('count')) + .where('playlistID', '=', playlist.id) + .where((eb) => eb.or([ + eb('playlistItems.artist', 'like', `%${filter}%`), + eb('playlistItems.title', 'like', `%${filter}%`), + ])) + .executeTakeFirstOrThrow(); + + const [ + playlistItemsRaw, + filtered, + total, + ] = await Promise.all([ + query.execute(), + filteredQuery, + totalQuery, + ]); + + const playlistItems = playlistItemsRaw.map(playlistItemFromSelection); + + return new Page(playlistItems, { pageSize: pagination.limit, - // `count` can be the empty array if the playlist has no items - filtered: filter ? (count[0]?.filtered ?? 0) : playlist.media.length, - total: playlist.media.length, + filtered: Number(filtered.count), + total: Number(total.count), current: pagination, next: { @@ -322,140 +428,96 @@ class PlaylistsRepository { * Get playlists containing a particular Media. * * @typedef {object} GetPlaylistsContainingMediaOptions - * @prop {ObjectId} [author] + * @prop {UserID} [author] * @prop {string[]} [fields] - * - * @param {ObjectId} mediaID + * @param {MediaID} mediaID * @param {GetPlaylistsContainingMediaOptions} options - * @return {Promise} */ - async getPlaylistsContainingMedia(mediaID, options = {}) { - const { Playlist } = this.#uw.models; - - const aggregate = []; + async getPlaylistsContainingMedia(mediaID, options = {}, tx = this.#uw.db) { + let query = tx.selectFrom('playlists') + .select([ + 'playlists.id', + 'playlists.userID', + 'playlists.name', + (eb) => jsonLength(eb.ref('playlists.items')).as('size'), + 'playlists.createdAt', + 'playlists.updatedAt', + ]) + .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID') + .where('playlistItems.mediaID', '=', mediaID) + .groupBy('playlistItems.playlistID'); if (options.author) { - aggregate.push({ $match: { author: options.author } }); - } - - aggregate.push( - // populate media array - { - $lookup: { - from: 'playlistitems', localField: 'media', foreignField: '_id', as: 'media', - }, - }, - // check if any media entry contains the id - { $match: { 'media.media': mediaID } }, - // reduce data sent in `media` array—this is still needed to match the result of other - // `getPlaylists()` functions - { $addFields: { media: '$media.media' } }, - ); - - if (options.fields) { - /** @type {Record} */ - const fields = {}; - options.fields.forEach((fieldName) => { - fields[fieldName] = 1; - }); - aggregate.push({ - $project: fields, - }); + query = query.where('playlists.userID', '=', options.author); } - const playlists = await Playlist.aggregate(aggregate, { maxTimeMS: 5_000 }); - return playlists.map((raw) => Playlist.hydrate(raw)); + const playlists = await query.execute(); + return playlists; } /** * Get playlists that contain any of the given medias. If multiple medias are in a single * playlist, that playlist will be returned multiple times, keyed on the media's unique ObjectId. * - * @param {ObjectId[]} mediaIDs - * @param {{ author?: ObjectId }} options - * @return {Promise>} - * A map of stringified `Media` `ObjectId`s to the Playlist objects that contain them. + * @param {MediaID[]} mediaIDs + * @param {{ author?: UserID }} options + * @returns A map of media IDs to the Playlist objects that contain them. */ - async getPlaylistsContainingAnyMedia(mediaIDs, options = {}) { - const { Playlist } = this.#uw.models; - - const aggregate = []; + async getPlaylistsContainingAnyMedia(mediaIDs, options = {}, tx = this.#uw.db) { + /** @type {Multimap} */ + const playlistsByMediaID = new Multimap(); + if (mediaIDs.length === 0) { + return playlistsByMediaID; + } + let query = tx.selectFrom('playlists') + .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID') + .select([ + 'playlists.id', + 'playlists.userID', + 'playlists.name', + (eb) => jsonLength(eb.ref('playlists.items')).as('size'), + 'playlists.createdAt', + 'playlists.updatedAt', + 'playlistItems.mediaID', + ]) + .where('playlistItems.mediaID', 'in', mediaIDs); if (options.author) { - aggregate.push({ $match: { author: options.author } }); + query = query.where('playlists.userID', '=', options.author); } - aggregate.push( - // Store the `size` so we can remove the `.media` property later. - { $addFields: { size: { $size: '$media' } } }, - // Store the playlist data on a property so lookup data does not pollute it. - // The result data is easier to process as separate {playlist, media} properties. - { $replaceRoot: { newRoot: { playlist: '$$ROOT' } } }, - // Find the playlist items in each playlist. - { - $lookup: { - from: 'playlistitems', - localField: 'playlist.media', - foreignField: '_id', - as: 'media', - }, - }, - // Unwind so we can match on individual playlist items. - { $unwind: '$media' }, - { - $match: { - 'media.media': { $in: mediaIDs }, - }, - }, - // Omit the potentially large list of media IDs that we don't use. - { $project: { 'playlist.media': 0 } }, - ); - - const pairs = await Playlist.aggregate(aggregate); - - const playlistsByMediaID = new Map(); - pairs.forEach(({ playlist, media }) => { - const stringID = media.media.toString(); - const playlists = playlistsByMediaID.get(stringID); - if (playlists) { - playlists.push(playlist); - } else { - playlistsByMediaID.set(stringID, [playlist]); - } - }); + const playlists = await query.execute(); + for (const { mediaID, ...playlist } of playlists) { + playlistsByMediaID.set(mediaID, playlist); + } return playlistsByMediaID; } /** - * Bulk create playlist items from arbitrary sources. + * Load media for all the given source type/source IDs. * * @param {User} user - * @param {PlaylistItemDesc[]} items + * @param {{ sourceType: string, sourceID: string }[]} items */ - async createPlaylistItems(user, items) { - const { Media, PlaylistItem } = this.#uw.models; - - if (!items.every(isValidPlaylistItem)) { - throw new Error('Cannot add a playlist item without a proper media source type and ID.'); - } + async resolveMedia(user, items) { + const { db } = this.#uw; // Group by source so we can retrieve all unknown medias from the source in // one call. - const itemsBySourceType = groupBy(items, 'sourceType'); - /** - * @type {{ media: Media, artist: string, title: string, start: number, end: number }[]} - */ - const playlistItems = []; + const itemsBySourceType = ObjectGroupBy(items, (item) => item.sourceType); + /** @type {Map} */ + const allMedias = new Map(); const promises = Object.entries(itemsBySourceType).map(async ([sourceType, sourceItems]) => { - /** @type {Media[]} */ - const knownMedias = await Media.find({ - sourceType, - sourceID: { $in: sourceItems.map((item) => String(item.sourceID)) }, - }); + const knownMedias = await db.selectFrom('media') + .where('sourceType', '=', sourceType) + .where('sourceID', 'in', sourceItems.map((item) => String(item.sourceID))) + .selectAll() + .execute(); /** @type {Set} */ const knownMediaIDs = new Set(); knownMedias.forEach((knownMedia) => { + allMedias.set(`${knownMedia.sourceType}:${knownMedia.sourceID}`, knownMedia); knownMediaIDs.add(knownMedia.sourceID); }); @@ -467,30 +529,38 @@ class PlaylistsRepository { } }); - let allMedias = knownMedias; if (unknownMediaIDs.length > 0) { // @ts-expect-error TS2322 - const unknownMedias = await this.#uw.source(sourceType) - .get(user, unknownMediaIDs); - allMedias = allMedias.concat(await Media.create(unknownMedias)); - } - - const itemsWithMedia = sourceItems.map((item) => { - const media = allMedias.find((compare) => compare.sourceID === String(item.sourceID)); - if (!media) { - throw new MediaNotFoundError({ sourceType: item.sourceType, sourceID: item.sourceID }); + const unknownMedias = await this.#uw.source(sourceType).get(user, unknownMediaIDs); + for (const media of unknownMedias) { + const newMedia = await db.insertInto('media') + .values({ + id: /** @type {MediaID} */ (randomUUID()), + sourceType: media.sourceType, + sourceID: media.sourceID, + sourceData: jsonb(media.sourceData), + artist: media.artist, + title: media.title, + duration: media.duration, + thumbnail: media.thumbnail, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + allMedias.set(`${media.sourceType}:${media.sourceID}`, newMedia); } - return toPlaylistItem(item, media); - }); - playlistItems.push(...itemsWithMedia); + } }); await Promise.all(promises); - if (playlistItems.length === 0) { - return []; + for (const item of items) { + if (!allMedias.has(`${item.sourceType}:${item.sourceID}`)) { + throw new MediaNotFoundError({ sourceType: item.sourceType, sourceID: item.sourceID }); + } } - return PlaylistItem.create(playlistItems); + + return allMedias; } /** @@ -498,105 +568,195 @@ class PlaylistsRepository { * * @param {Playlist} playlist * @param {PlaylistItemDesc[]} items - * @param {{ after?: ObjectId|null }} options - * @returns {Promise<{ - * added: PlaylistItem[], - * afterID: ObjectId?, - * playlistSize: number, - * }>} + * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} [options] */ - async addPlaylistItems(playlist, items, { after = null } = {}) { + async addPlaylistItems(playlist, items, options = { at: 'end' }) { const { users } = this.#uw; - const user = await users.getUser(playlist.author); + const user = await users.getUser(playlist.userID); if (!user) { - throw new UserNotFoundError({ id: playlist.author }); + throw new UserNotFoundError({ id: playlist.userID }); } - const newItems = await this.createPlaylistItems(user, items); - const oldMedia = playlist.media; - const insertIndex = after === null ? -1 : oldMedia.findIndex((item) => item.equals(after)); - playlist.media = [ - ...oldMedia.slice(0, insertIndex + 1), - ...newItems.map((item) => item._id), - ...oldMedia.slice(insertIndex + 1), - ]; + const medias = await this.resolveMedia(user, items); + const playlistItems = items.map((item) => { + const media = medias.get(`${item.sourceType}:${item.sourceID}`); + if (media == null) { + throw new Error('resolveMedia() should have errored'); + } + const { start, end } = getStartEnd(item, media); + return { + id: /** @type {PlaylistItemID} */ (randomUUID()), + media: media, + artist: item.artist ?? media.artist, + title: item.title ?? media.title, + start, + end, + }; + }); - await playlist.save(); + const result = await this.#uw.db.transaction().execute(async (tx) => { + for (const item of playlistItems) { + // TODO: use a prepared statement + await tx.insertInto('playlistItems') + .values({ + id: item.id, + playlistID: playlist.id, + mediaID: item.media.id, + artist: item.artist, + title: item.title, + start: item.start, + end: item.end, + }) + .execute(); + } - return { - added: newItems, - afterID: after, - playlistSize: playlist.media.length, - }; + const result = await tx.selectFrom('playlists') + .select(sql`json(items)`.as('items')) + .where('id', '=', playlist.id) + .executeTakeFirstOrThrow(); + + /** @type {PlaylistItemID[]} */ + const oldItems = result?.items ? JSON.parse(/** @type {string} */ (result.items)) : []; + + /** @type {PlaylistItemID | null} */ + let after; + let newItems; + if ('after' in options) { + after = options.after; + const insertIndex = oldItems.indexOf(options.after); + newItems = [ + ...oldItems.slice(0, insertIndex + 1), + ...playlistItems.map((item) => item.id), + ...oldItems.slice(insertIndex + 1), + ]; + } else if (options.at === 'start') { + after = null; + newItems = playlistItems.map((item) => item.id).concat(oldItems); + } else { + newItems = oldItems.concat(playlistItems.map((item) => item.id)); + after = oldItems.at(-1) ?? null; + } + + await tx.updateTable('playlists') + .where('id', '=', playlist.id) + .set({ items: jsonb(newItems) }) + .executeTakeFirstOrThrow(); + + return { + added: playlistItems, + afterID: after, + playlistSize: newItems.length, + }; + }); + + return result; } /** - * @param {PlaylistItem} item - * @param {object} patch + * @param {Omit} item + * @param {Partial>} patch * @returns {Promise} */ - - async updatePlaylistItem(item, patch = {}) { - Object.assign(item, patch); - await item.save(); - return item; + async updatePlaylistItem(item, patch = {}, tx = this.#uw.db) { + const updatedItem = await tx.updateTable('playlistItems') + .where('id', '=', item.id) + .set(patch) + .returningAll() + .executeTakeFirstOrThrow(); + + return updatedItem; } /** * @param {Playlist} playlist - * @param {ObjectId[]} itemIDs - * @param {{ afterID: ObjectId? }} options + * @param {PlaylistItemID[]} itemIDs + * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} options */ + async movePlaylistItems(playlist, itemIDs, options) { + const { db } = this.#uw; + + await db.transaction().execute(async (tx) => { + const result = await tx.selectFrom('playlists') + .select(sql`json(items)`.as('items')) + .where('id', '=', playlist.id) + .executeTakeFirst(); + + const items = result?.items ? JSON.parse(/** @type {string} */ (result.items)) : []; + const itemIDsInPlaylist = new Set(items); + const itemIDsToMove = new Set(itemIDs.filter((itemID) => itemIDsInPlaylist.has(itemID))); + + /** @type {PlaylistItemID[]} */ + let newItemIDs = []; + /** Index in the new item array to move the item IDs to. */ + let insertIndex = 0; + let index = 0; + for (const itemID of itemIDsInPlaylist) { + if (!itemIDsToMove.has(itemID)) { + index += 1; + newItemIDs.push(itemID); + } + if ('after' in options && itemID === options.after) { + insertIndex = index; + } + } - async movePlaylistItems(playlist, itemIDs, { afterID }) { - // Use a plain array instead of a mongoose array because we need `splice()`. - const itemsInPlaylist = [...playlist.media]; - const itemIDsInPlaylist = new Set(itemsInPlaylist.map((item) => `${item}`)); - // Only attempt to move items that are actually in the playlist. - const itemIDsToInsert = itemIDs.filter((id) => itemIDsInPlaylist.has(`${id}`)); - - // Remove the items that we are about to move. - const newMedia = itemsInPlaylist.filter((item) => ( - itemIDsToInsert.every((insert) => !insert.equals(item)) - )); - // Reinsert items at their new position. - const insertIndex = afterID - ? newMedia.findIndex((item) => item.equals(afterID)) - : -1; - newMedia.splice(insertIndex + 1, 0, ...itemIDsToInsert); - playlist.media = newMedia; - - await playlist.save(); + if ('after' in options) { + newItemIDs = [ + ...newItemIDs.slice(0, insertIndex + 1), + ...itemIDsToMove, + ...newItemIDs.slice(insertIndex + 1), + ]; + } else if (options.at === 'start') { + newItemIDs = [...itemIDsToMove, ...newItemIDs]; + } else { + newItemIDs = [...newItemIDs, ...itemIDsToMove]; + } + + await tx.updateTable('playlists') + .where('id', '=', playlist.id) + .set('items', jsonb(newItemIDs)) + .execute(); + }); return {}; } /** * @param {Playlist} playlist - * @param {ObjectId[]} itemIDs + * @param {PlaylistItemID[]} itemIDs */ async removePlaylistItems(playlist, itemIDs) { - const { PlaylistItem } = this.#uw.models; - - // Only remove items that are actually in this playlist. - const stringIDs = new Set(itemIDs.map((item) => String(item))); - /** @type {ObjectId[]} */ - const toRemove = []; - /** @type {ObjectId[]} */ - const toKeep = []; - playlist.media.forEach((itemID) => { - if (stringIDs.has(`${itemID}`)) { - toRemove.push(itemID); - } else { - toKeep.push(itemID); - } - }); - - playlist.media = toKeep; - await playlist.save(); - await PlaylistItem.deleteMany({ _id: { $in: toRemove } }); + const { db } = this.#uw; + + await db.transaction().execute(async (tx) => { + const rows = await tx.selectFrom('playlists') + .innerJoin((eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'), (join) => join) + .select('playlistItemIDs.value as itemID') + .where('playlists.id', '=', playlist.id) + .execute(); + + // Only remove items that are actually in this playlist. + const set = new Set(itemIDs); + /** @type {PlaylistItemID[]} */ + const toRemove = []; + /** @type {PlaylistItemID[]} */ + const toKeep = []; + rows.forEach(({ itemID }) => { + if (set.has(itemID)) { + toRemove.push(itemID); + } else { + toKeep.push(itemID); + } + }); - return {}; + await tx.updateTable('playlists') + .where('id', '=', playlist.id) + .set({ items: jsonb(toKeep) }) + .execute(); + await tx.deleteFrom('playlistItems') + .where('id', 'in', toRemove) + .execute(); + }); } } diff --git a/src/plugins/users.js b/src/plugins/users.js index 77324205..5734e545 100644 --- a/src/plugins/users.js +++ b/src/plugins/users.js @@ -1,11 +1,17 @@ +import lodash from 'lodash'; import bcrypt from 'bcryptjs'; -import escapeStringRegExp from 'escape-string-regexp'; import Page from '../Page.js'; import { IncorrectPasswordError, UserNotFoundError } from '../errors/index.js'; +import { slugify } from 'transliteration'; +import { jsonGroupArray } from '../utils/sqlite.js'; +import { sql } from 'kysely'; +import { randomUUID } from 'crypto'; + +const { pick, omit } = lodash; /** - * @typedef {import('../models/index.js').User} User - * @typedef {import('../models/index.js').Authentication} Authentication + * @typedef {import('../schema.js').User} User + * @typedef {import('../schema.js').UserID} UserID */ /** @@ -15,12 +21,15 @@ function encryptPassword(password) { return bcrypt.hash(password, 10); } -/** - * @param {User} user - */ -function getDefaultAvatar(user) { - return `https://sigil.u-wave.net/${user.id}`; -} +/** @param {import('kysely').ExpressionBuilder} eb */ +const userRolesColumn = (eb) => eb.selectFrom('userRoles') + .where('userRoles.userID', '=', eb.ref('users.id')) + .select((sb) => jsonGroupArray(sb.ref('userRoles.role')).as('roles')); +/** @param {import('kysely').ExpressionBuilder} eb */ +const avatarColumn = (eb) => eb.fn.coalesce( + 'users.avatar', + /** @type {import('kysely').RawBuilder} */ (sql`concat('https://sigil.u-wave.net/', ${eb.ref('users.id')})`), +); class UsersRepository { #uw; @@ -40,43 +49,56 @@ class UsersRepository { * @param {{ offset?: number, limit?: number }} [pagination] */ async getUsers(filter, pagination = {}) { - const { User } = this.#uw.models; + const { db } = this.#uw; const { offset = 0, limit = 50, } = pagination; - const query = User.find() - .skip(offset) + let query = db.selectFrom('users') + .select([ + 'users.id', + 'users.username', + 'users.slug', + 'users.activePlaylistID', + 'users.pendingActivation', + 'users.createdAt', + 'users.updatedAt', + (eb) => avatarColumn(eb).as('avatar'), + (eb) => userRolesColumn(eb).as('roles'), + ]) + .offset(offset) .limit(limit); - let queryFilter = null; - - if (filter) { - queryFilter = { - username: new RegExp(escapeStringRegExp(filter)), - }; - query.where(queryFilter); + if (filter != null) { + query = query.where('username', 'like', `%${filter}%`); } - const totalPromise = User.estimatedDocumentCount().exec(); + const totalQuery = db.selectFrom('users') + .select((eb) => eb.fn.countAll().as('count')) + .executeTakeFirstOrThrow(); + + const filteredQuery = filter == null ? totalQuery : db.selectFrom('users') + .select((eb) => eb.fn.countAll().as('count')) + .where('username', 'like', `%${filter}%`) + .executeTakeFirstOrThrow(); const [ users, filtered, total, ] = await Promise.all([ - query, - queryFilter ? User.find().where(queryFilter).countDocuments() : totalPromise, - totalPromise, + query.execute(), + filteredQuery, + totalQuery, ]); return new Page(users, { pageSize: limit, - filtered, - total, + filtered: Number(filtered.count), + total: Number(total.count), current: { offset, limit }, - next: offset + limit <= total ? { offset: offset + limit, limit } : null, + next: offset + limit <= Number(total.count) ? { offset: offset + limit, limit } : null, previous: offset > 0 ? { offset: Math.max(offset - limit, 0), limit } : null, @@ -86,27 +108,52 @@ class UsersRepository { /** * Get a user object by ID. * - * @param {import('mongodb').ObjectId|string} id - * @returns {Promise} + * @param {UserID} id + * @param {import('../schema.js').Kysely} [tx] */ - async getUser(id) { - const { User } = this.#uw.models; - const user = await User.findById(id); + async getUser(id, tx) { + const [user] = await this.getUsersByIds([id], tx); + return user ?? null; + } - return user; + /** + * @param {UserID[]} ids + * @param {import('../schema.js').Kysely} [tx] + */ + async getUsersByIds(ids, tx = this.#uw.db) { + const users = await tx.selectFrom('users') + .where('id', 'in', ids) + .select([ + 'users.id', + 'users.username', + 'users.slug', + 'users.activePlaylistID', + 'users.pendingActivation', + 'users.createdAt', + 'users.updatedAt', + (eb) => avatarColumn(eb).as('avatar'), + (eb) => userRolesColumn(eb).as('roles'), + ]) + .execute(); + + for (const user of users) { + const roles = /** @type {string[]} */ (JSON.parse( + /** @type {string} */ (/** @type {unknown} */ (user.roles)), + )); + Object.assign(user, { roles }); + } + + return /** @type {import('type-fest').SetNonNullable<(typeof users)[0], 'roles'>[]} */ (users); } /** * @typedef {object} LocalLoginOptions * @prop {string} email * @prop {string} password - * * @typedef {object} SocialLoginOptions * @prop {import('passport').Profile} profile - * * @typedef {LocalLoginOptions & { type: 'local' }} DiscriminatedLocalLoginOptions * @typedef {SocialLoginOptions & { type: string }} DiscriminatedSocialLoginOptions - * * @param {DiscriminatedLocalLoginOptions | DiscriminatedSocialLoginOptions} options * @returns {Promise} */ @@ -121,25 +168,36 @@ class UsersRepository { /** * @param {LocalLoginOptions} options - * @returns {Promise} */ async localLogin({ email, password }) { - const { Authentication } = this.#uw.models; - - /** @type {null | (Authentication & { user: User })} */ - const auth = /** @type {any} */ (await Authentication.findOne({ - email: email.toLowerCase(), - }).populate('user').exec()); - if (!auth || !auth.hash) { + const user = await this.#uw.db.selectFrom('users') + .where('email', '=', email) + .select([ + 'users.id', + 'users.username', + 'users.slug', + (eb) => avatarColumn(eb).as('avatar'), + 'users.activePlaylistID', + 'users.pendingActivation', + 'users.createdAt', + 'users.updatedAt', + 'users.password', + ]) + .executeTakeFirst(); + if (!user) { throw new UserNotFoundError({ email }); } - const correct = await bcrypt.compare(password, auth.hash); + if (!user.password) { + throw new IncorrectPasswordError(); + } + + const correct = await bcrypt.compare(password, user.password); if (!correct) { throw new IncorrectPasswordError(); } - return auth.user; + return omit(user, 'password'); } /** @@ -154,7 +212,7 @@ class UsersRepository { username: profile.displayName, avatar: profile.photos && profile.photos.length > 0 ? profile.photos[0].value : undefined, }; - return this.#uw.users.findOrCreateSocialUser(user); + return this.findOrCreateSocialUser(user); } /** @@ -163,7 +221,6 @@ class UsersRepository { * @prop {string} id * @prop {string} username * @prop {string} [avatar] - * * @param {FindOrCreateSocialUserOptions} options * @returns {Promise} */ @@ -173,164 +230,165 @@ class UsersRepository { username, avatar, }) { - const { User, Authentication } = this.#uw.models; + const { db } = this.#uw; this.#logger.info({ type, id }, 'find or create social'); - // we need this type assertion because the `user` property actually contains - // an ObjectId in this return value. We are definitely filling in a User object - // below before using this variable. - /** @type {null | (Omit & { user: User })} */ - let auth = await Authentication.findOne({ type, id }); - if (auth) { - await auth.populate('user'); - - if (avatar && auth.avatar !== avatar) { - auth.avatar = avatar; - await auth.save(); - } - } else { - const user = new User({ - username: username ? username.replace(/\s/g, '') : `${type}.${id}`, - roles: ['user'], - pendingActivation: type, - }); - await user.validate(); - - // @ts-expect-error TS2322: the type check fails because the `user` property actually contains - // an ObjectId in this return value. We are definitely filling in a User object below before - // using this variable. - auth = new Authentication({ - type, - user, - id, - avatar, - // HACK, providing a fake email so we can use `unique: true` on emails - email: `${id}@${type}.sociallogin`, - }); - - // Just so typescript knows `auth` is not null here. - if (!auth) throw new TypeError(); - - try { - await Promise.all([ - user.save(), - auth.save(), - ]); - } catch (e) { - if (!auth.isNew) { - await auth.deleteOne(); + const user = await db.transaction().execute(async (tx) => { + const auth = await tx.selectFrom('authServices') + .innerJoin('users', 'users.id', 'authServices.userID') + .where('service', '=', type) + .where('serviceID', '=', id) + .select([ + 'authServices.service', + 'authServices.serviceID', + 'authServices.serviceAvatar', + 'users.id', + 'users.username', + 'users.slug', + 'users.activePlaylistID', + 'users.pendingActivation', + 'users.createdAt', + 'users.updatedAt', + ]) + .executeTakeFirst(); + + if (auth) { + if (avatar && auth.serviceAvatar !== avatar) { + auth.serviceAvatar = avatar; } - await user.deleteOne(); - throw e; - } - this.#uw.publish('user:create', { - user: user.id, - auth: { type, id }, - }); - } + return Object.assign( + pick(auth, ['id', 'username', 'slug', 'activePlaylistID', 'pendingActivation', 'createdAt', 'updatedAt']), + { avatar: null }, + ); + } else { + const user = await tx.insertInto('users') + .values({ + id: /** @type {UserID} */ (randomUUID()), + username: username ? username.replace(/\s/g, '') : `${type}.${id}`, + slug: slugify(username), + pendingActivation: true, + avatar, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + await tx.insertInto('authServices') + .values({ + userID: user.id, + service: type, + serviceID: id, + serviceAvatar: avatar, + }) + .executeTakeFirstOrThrow(); + + this.#uw.publish('user:create', { + user: user.id, + auth: { type, id }, + }); + + return user; + } + }); - return auth.user; + return user; } /** * @param {{ username: string, email: string, password: string }} props - * @returns {Promise} */ async createUser({ username, email, password, }) { - const { User, Authentication } = this.#uw.models; + const { acl, db } = this.#uw; this.#logger.info({ username, email: email.toLowerCase() }, 'create user'); const hash = await encryptPassword(password); - const user = new User({ - username, - roles: ['user'], - }); - await user.validate(); - - const auth = new Authentication({ - type: 'local', - user, - email: email.toLowerCase(), - hash, - }); - - try { - await Promise.all([ - user.save(), - auth.save(), - ]); - // Two-stage saving to let mongodb decide the user ID before we generate an avatar URL. - user.avatar = getDefaultAvatar(user); - await user.save(); - } catch (e) { - if (!auth.isNew) { - await auth.deleteOne(); - } - await user.deleteOne(); - throw e; - } + const user = await db.insertInto('users') + .values({ + id: /** @type {UserID} */ (randomUUID()), + username, + email, + password: hash, + slug: slugify(username), + pendingActivation: /** @type {boolean} */ (/** @type {unknown} */ (0)), + }) + .returning([ + 'users.id', + 'users.username', + 'users.slug', + (eb) => avatarColumn(eb).as('avatar'), + 'users.activePlaylistID', + 'users.pendingActivation', + 'users.createdAt', + 'users.updatedAt', + ]) + .executeTakeFirstOrThrow(); + + const roles = ['user']; + await acl.allow(user, roles); this.#uw.publish('user:create', { user: user.id, auth: { type: 'local', email: email.toLowerCase() }, }); - return user; + return Object.assign(user, { roles }); } /** - * @param {import('mongodb').ObjectId} id + * @param {UserID} id * @param {string} password */ async updatePassword(id, password) { - const { Authentication } = this.#uw.models; - - const user = await this.getUser(id); - if (!user) throw new UserNotFoundError({ id }); + const { db } = this.#uw; const hash = await encryptPassword(password); - - const auth = await Authentication.findOneAndUpdate({ - // TODO re enable once a migrations thing is set up so that all existing - // records can be updated to add this. - // type: 'local', - user: user._id, - }, { hash }); - - if (!auth) { - throw new UserNotFoundError({ id: user.id }); + const result = await db.updateTable('users') + .where('id', '=', id) + .set({ password: hash }) + .executeTakeFirst(); + if (Number(result.numUpdatedRows) === 0) { + throw new UserNotFoundError({ id }); } } /** - * @param {import('mongodb').ObjectId|string} id - * @param {Record} update + * @param {UserID} id + * @param {Partial>} update * @param {{ moderator?: User }} [options] */ async updateUser(id, update = {}, options = {}) { + const { db } = this.#uw; + const user = await this.getUser(id); if (!user) throw new UserNotFoundError({ id }); this.#logger.info({ userId: user.id, update }, 'update user'); - const moderator = options && options.moderator; + const moderator = options.moderator; - /** @type {Record} */ + /** @type {typeof update} */ const old = {}; Object.keys(update).forEach((key) => { - // FIXME We should somehow make sure that the type of `key` extends `keyof LeanUser` here. + // FIXME We should somehow make sure that the type of `key` extends `keyof User` here. // @ts-expect-error TS7053 old[key] = user[key]; }); Object.assign(user, update); - await user.save(); + const updatesFromDatabase = await db.updateTable('users') + .where('id', '=', id) + .set(update) + .returning(['username']) + .executeTakeFirst(); + if (!updatesFromDatabase) { + throw new UserNotFoundError({ id }); + } + Object.assign(user, updatesFromDatabase); // Take updated keys from the Model again, // as it may apply things like Unicode normalization on the values. diff --git a/src/plugins/waitlist.js b/src/plugins/waitlist.js index d9929a0e..0e68b42f 100644 --- a/src/plugins/waitlist.js +++ b/src/plugins/waitlist.js @@ -9,14 +9,15 @@ import { UserIsPlayingError, } from '../errors/index.js'; import routes from '../routes/waitlist.js'; +import { Permissions } from './acl.js'; const schema = JSON.parse( fs.readFileSync(new URL('../schemas/waitlist.json', import.meta.url), 'utf8'), ); /** - * @typedef {import('../models/index.js').User} User - * + * @typedef {import('../schema.js').UserID} UserID + * @typedef {import('../schema.js').User} User * @typedef {{ cycle: boolean, locked: boolean }} WaitlistSettings */ @@ -100,7 +101,7 @@ class Waitlist { schema['uw:key'], /** * @param {WaitlistSettings} _settings - * @param {string|null} userID + * @param {UserID|null} userID * @param {Partial} patch */ (_settings, userID, patch) => { @@ -129,11 +130,11 @@ class Waitlist { */ async #hasPlayablePlaylist(user) { const { playlists } = this.#uw; - if (!user.activePlaylist) { + if (!user.activePlaylistID) { return false; } - const playlist = await playlists.getUserPlaylist(user, user.activePlaylist); + const playlist = await playlists.getUserPlaylist(user, user.activePlaylistID); return playlist && playlist.size > 0; } @@ -164,10 +165,10 @@ class Waitlist { } /** - * @returns {Promise} + * @returns {Promise} */ getUserIDs() { - return this.#uw.redis.lrange('waitlist', 0, -1); + return /** @type {Promise} */ (this.#uw.redis.lrange('waitlist', 0, -1)); } /** @@ -175,7 +176,7 @@ class Waitlist { * adding someone else to the waitlist. * TODO maybe split this up and let http-api handle the difference * - * @param {string} userID + * @param {UserID} userID * @param {{moderator?: User}} [options] */ async addUser(userID, options = {}) { @@ -187,14 +188,14 @@ class Waitlist { const isAddingOtherUser = moderator && user.id !== moderator.id; if (isAddingOtherUser) { - if (!(await acl.isAllowed(moderator, 'waitlist.add'))) { + if (!(await acl.isAllowed(moderator, Permissions.WaitlistAdd))) { throw new PermissionError({ requiredRole: 'waitlist.add', }); } } - const canForceJoin = await acl.isAllowed(user, 'waitlist.join.locked'); + const canForceJoin = await acl.isAllowed(user, Permissions.WaitlistJoinLocked); if (!isAddingOtherUser && !canForceJoin && await this.isLocked()) { throw new WaitlistLockedError(); } @@ -204,8 +205,7 @@ class Waitlist { } try { - /** @type {string[]} */ - const waitlist = await this.#uw.redis['uw:addToWaitlist'](...ADD_TO_WAITLIST_SCRIPT.keys, user.id); + const waitlist = /** @type {UserID[]} */ (await this.#uw.redis['uw:addToWaitlist'](...ADD_TO_WAITLIST_SCRIPT.keys, user.id)); if (isAddingOtherUser) { this.#uw.publish('waitlist:add', { @@ -233,7 +233,7 @@ class Waitlist { } /** - * @param {string} userID + * @param {UserID} userID * @param {number} position * @param {{moderator: User}} options * @returns {Promise} @@ -241,7 +241,7 @@ class Waitlist { async moveUser(userID, position, { moderator }) { const { users } = this.#uw; - const user = await users.getUser(userID.toLowerCase()); + const user = await users.getUser(userID); if (!user) { throw new UserNotFoundError({ id: userID }); } @@ -251,8 +251,7 @@ class Waitlist { } try { - /** @type {string[]} */ - const waitlist = await this.#uw.redis['uw:moveWaitlist'](...MOVE_WAITLIST_SCRIPT.keys, user.id, position); + const waitlist = /** @type {UserID[]} */ (await this.#uw.redis['uw:moveWaitlist'](...MOVE_WAITLIST_SCRIPT.keys, user.id, position)); this.#uw.publish('waitlist:move', { userID: user.id, @@ -272,7 +271,7 @@ class Waitlist { } /** - * @param {string} userID + * @param {UserID} userID * @param {{moderator: User}} options * @returns {Promise} */ @@ -284,7 +283,7 @@ class Waitlist { } const isRemoving = moderator && user.id !== moderator.id; - if (isRemoving && !(await acl.isAllowed(moderator, 'waitlist.remove'))) { + if (isRemoving && !(await acl.isAllowed(moderator, Permissions.WaitlistRemove))) { throw new PermissionError({ requiredRole: 'waitlist.remove', }); diff --git a/src/redisMessages.ts b/src/redisMessages.ts index 48af00f2..099e32b2 100644 --- a/src/redisMessages.ts +++ b/src/redisMessages.ts @@ -1,11 +1,11 @@ -import { JsonObject } from 'type-fest'; // eslint-disable-line import/no-unresolved, n/no-missing-import, n/no-unpublished-import +import type { JsonObject } from 'type-fest'; // eslint-disable-line n/no-missing-import, n/no-unpublished-import +import type { HistoryEntryID, PlaylistID, UserID } from './schema.js'; export type ServerActionParameters = { 'advance:complete': { - historyID: string, - userID: string, - playlistID: string, - itemID: string, + historyID: HistoryEntryID, + userID: UserID, + playlistID: PlaylistID, media: { artist: string, title: string, @@ -23,117 +23,117 @@ export type ServerActionParameters = { } | null, 'booth:skip': { - userID: string, - moderatorID: string | null, + userID: UserID, + moderatorID: UserID | null, reason: string | null, }, 'booth:replace': { - userID: string, - moderatorID: string | null, + userID: UserID, + moderatorID: UserID | null, }, 'chat:message': { id: string, - userID: string, + userID: UserID, message: string, timestamp: number, }, 'chat:delete': { - filter: { id: string } | { userID: string } | Record, - moderatorID: string, + filter: { id: string } | { userID: UserID } | Record, + moderatorID: UserID | null, }, 'chat:mute': { - moderatorID: string, - userID: string, + moderatorID: UserID, + userID: UserID, duration: number, }, 'chat:unmute': { - moderatorID: string, - userID: string, + moderatorID: UserID, + userID: UserID, }, 'configStore:update': { key: string, - user: string | null, + user: UserID | null, patch: Record, }, 'booth:vote': { - userID: string, + userID: UserID, direction: 1 | -1, }, 'booth:favorite': { - userID: string, - playlistID: string, + userID: UserID, + playlistID: PlaylistID, }, 'playlist:cycle': { - userID: string, - playlistID: string, + userID: UserID, + playlistID: PlaylistID, }, 'waitlist:join': { - userID: string, - waitlist: string[], + userID: UserID, + waitlist: UserID[], }, 'waitlist:leave': { - userID: string, - waitlist: string[], + userID: UserID, + waitlist: UserID[], }, 'waitlist:add': { - userID: string, - moderatorID: string, + userID: UserID, + moderatorID: UserID, position: number, - waitlist: string[], + waitlist: UserID[], }, 'waitlist:remove': { - userID: string, - moderatorID: string, - waitlist: string[], + userID: UserID, + moderatorID: UserID, + waitlist: UserID[], }, 'waitlist:move': { - userID: string, - moderatorID: string, + userID: UserID, + moderatorID: UserID, position: number, - waitlist: string[], + waitlist: UserID[], }, - 'waitlist:update': string[], + 'waitlist:update': UserID[], 'waitlist:clear': { - moderatorID: string, + moderatorID: UserID, }, 'waitlist:lock': { - moderatorID: string, + moderatorID: UserID, locked: boolean, }, 'acl:allow': { - userID: string, + userID: UserID, roles: string[], }, 'acl:disallow': { - userID: string, + userID: UserID, roles: string[], }, 'user:create': { - user: string, + user: UserID, auth: { type: 'local', email: string } | { type: string, id: string }, }, 'user:update': { - userID: string, - moderatorID: string, + userID: UserID, + moderatorID: UserID | null, old: Record, new: Record, }, - 'user:join': { userID: string }, - 'user:leave': { userID: string }, - 'user:logout': { userID: string }, + 'user:join': { userID: UserID }, + 'user:leave': { userID: UserID }, + 'user:logout': { userID: UserID }, 'user:ban': { - userID: string, - moderatorID: string, + userID: UserID, + moderatorID: UserID, permanent?: boolean, duration: number | null, expiresAt: number | null, }, 'user:unban': { - userID: string, - moderatorID: string, + userID: UserID, + moderatorID: UserID, }, 'http-api:socket:close': string, diff --git a/src/routes/acl.js b/src/routes/acl.js index 17c645b8..bff19273 100644 --- a/src/routes/acl.js +++ b/src/routes/acl.js @@ -4,6 +4,7 @@ import * as validations from '../validations.js'; import protect from '../middleware/protect.js'; import schema from '../middleware/schema.js'; import * as controller from '../controllers/acl.js'; +import { Permissions } from '../plugins/acl.js'; function aclRoutes() { return Router() @@ -15,14 +16,14 @@ function aclRoutes() { // PUT /roles/:name - Create a new role. .put( '/:name', - protect('acl.create'), + protect(Permissions.AclCreate), schema(validations.createAclRole), route(controller.createRole), ) // DELETE /roles/:name - Delete a new role. .delete( '/:name', - protect('acl.delete'), + protect(Permissions.AclDelete), schema(validations.deleteAclRole), route(controller.deleteRole), ); diff --git a/src/routes/bans.js b/src/routes/bans.js index 9a684810..db4f2625 100644 --- a/src/routes/bans.js +++ b/src/routes/bans.js @@ -2,24 +2,25 @@ import { Router } from 'express'; import route from '../route.js'; import protect from '../middleware/protect.js'; import * as controller from '../controllers/bans.js'; +import { Permissions } from '../plugins/acl.js'; function banRoutes() { return Router() .get( '/', - protect('users.bans.list'), + protect(Permissions.BanList), route(controller.getBans), ) .post( '/', - protect('users.bans.add'), + protect(Permissions.BanAdd), route(controller.addBan), ) .delete( '/:userID', - protect('users.bans.remove'), + protect(Permissions.BanRemove), route(controller.removeBan), ); } diff --git a/src/routes/booth.js b/src/routes/booth.js index 074bf8a6..18fc49a0 100644 --- a/src/routes/booth.js +++ b/src/routes/booth.js @@ -26,13 +26,6 @@ function boothRoutes() { schema(validations.leaveBooth), route(controller.leaveBooth), ) - // POST /booth/replace - Replace the current DJ with someone else. - .post( - '/replace', - protect('booth.replace'), - schema(validations.replaceBooth), - route(controller.replaceBooth), - ) // GET /booth/:historyID/vote - Get the current user's vote for the current play. .get( '/:historyID/vote', diff --git a/src/routes/chat.js b/src/routes/chat.js index 73ca83ee..31e0fed4 100644 --- a/src/routes/chat.js +++ b/src/routes/chat.js @@ -4,26 +4,27 @@ import * as validations from '../validations.js'; import protect from '../middleware/protect.js'; import schema from '../middleware/schema.js'; import * as controller from '../controllers/chat.js'; +import { Permissions } from '../plugins/acl.js'; function chatRoutes() { return Router() // DELETE /chat/ - Clear the chat (delete all messages). .delete( '/', - protect('chat.delete'), + protect(Permissions.ChatDelete), route(controller.deleteAll), ) // DELETE /chat/user/:id - Delete all messages by a user. .delete( '/user/:id', - protect('chat.delete'), + protect(Permissions.ChatDelete), schema(validations.deleteChatByUser), route(controller.deleteByUser), ) // DELETE /chat/:id - Delete a chat message. .delete( '/:id', - protect('chat.delete'), + protect(Permissions.ChatDelete), schema(validations.deleteChatMessage), route(controller.deleteMessage), ); diff --git a/src/routes/motd.js b/src/routes/motd.js index 60998da2..ea6df459 100644 --- a/src/routes/motd.js +++ b/src/routes/motd.js @@ -4,6 +4,7 @@ import * as validations from '../validations.js'; import protect from '../middleware/protect.js'; import schema from '../middleware/schema.js'; import * as controller from '../controllers/motd.js'; +import { Permissions } from '../plugins/acl.js'; function motdRoutes() { return Router() @@ -15,7 +16,7 @@ function motdRoutes() { // PUT /motd/ - Set the message of the day. .put( '/', - protect('motd.set'), + protect(Permissions.MotdSet), schema(validations.setMotd), route(controller.setMotd), ); diff --git a/src/routes/playlists.js b/src/routes/playlists.js index f36094b6..141793be 100644 --- a/src/routes/playlists.js +++ b/src/routes/playlists.js @@ -73,7 +73,8 @@ function playlistRoutes() { route(controller.removePlaylistItems), ) // PUT /playlists/:id/move - Move playlist items. - // TODO This should probably not be a PUT + // TODO This should probably not be a PUT, but a POST + // It's not idempotent! .put( '/:id/move', schema(validations.movePlaylistItems), diff --git a/src/routes/server.js b/src/routes/server.js index 170c9fcf..9c656d51 100644 --- a/src/routes/server.js +++ b/src/routes/server.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import route from '../route.js'; import protect from '../middleware/protect.js'; import * as controller from '../controllers/server.js'; +import { Permissions } from '../plugins/acl.js'; function serverRoutes() { return Router() @@ -13,19 +14,19 @@ function serverRoutes() { // GET /server/config .get( '/config', - protect('admin'), + protect(Permissions.Super), route(controller.getAllConfig), ) // GET /server/config/:key .get( '/config/:key', - protect('admin'), + protect(Permissions.Super), route(controller.getConfig), ) // PUT /server/config/:key .put( '/config/:key', - protect('admin'), + protect(Permissions.Super), route(controller.updateConfig), ); } diff --git a/src/routes/users.js b/src/routes/users.js index 05fd99e4..f278862f 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -6,13 +6,14 @@ import schema from '../middleware/schema.js'; import rateLimit from '../middleware/rateLimit.js'; import * as controller from '../controllers/users.js'; import { NameChangeRateLimitError } from '../errors/index.js'; +import { Permissions } from '../plugins/acl.js'; function userRoutes() { return Router() // GET /users/ - List user accounts. .get( '/', - protect('users.list'), + protect(Permissions.UserList), route(controller.getUsers), ) // GET /users/:id - Show a single user. @@ -25,7 +26,7 @@ function userRoutes() { // TODO move this to /mutes/ namespace. .post( '/:id/mute', - protect('chat.mute'), + protect(Permissions.ChatMute), schema(validations.muteUser), route(controller.muteUser), ) @@ -33,7 +34,7 @@ function userRoutes() { // TODO move this to /mutes/ namespace. .delete( '/:id/mute', - protect('chat.unmute'), + protect(Permissions.ChatUnmute), schema(validations.unmuteUser), route(controller.unmuteUser), ) diff --git a/src/routes/waitlist.js b/src/routes/waitlist.js index f0d50397..5f456498 100644 --- a/src/routes/waitlist.js +++ b/src/routes/waitlist.js @@ -5,6 +5,7 @@ import protect from '../middleware/protect.js'; import requireActiveConnection from '../middleware/requireActiveConnection.js'; import schema from '../middleware/schema.js'; import * as controller from '../controllers/waitlist.js'; +import { Permissions } from '../plugins/acl.js'; function waitlistRoutes() { return Router() @@ -16,7 +17,7 @@ function waitlistRoutes() { // POST /waitlist/ - Add a user to the waitlist. .post( '/', - protect('waitlist.join'), + protect(Permissions.WaitlistJoin), requireActiveConnection(), schema(validations.joinWaitlist), route(controller.addToWaitlist), @@ -24,26 +25,26 @@ function waitlistRoutes() { // DELETE /waitlist/ - Clear the waitlist. .delete( '/', - protect('waitlist.clear'), + protect(Permissions.WaitlistClear), route(controller.clearWaitlist), ) // PUT /waitlist/move - Move a user to a different position in the waitlist. .put( '/move', - protect('waitlist.move'), + protect(Permissions.WaitlistMove), schema(validations.moveWaitlist), route(controller.moveWaitlist), ) // DELETE /waitlist/:id - Remove a user from the waitlist. .delete( '/:id', - protect('waitlist.leave'), + protect(Permissions.WaitlistLeave), route(controller.removeFromWaitlist), ) // PUT /waitlist/lock - Lock or unlock the waitlist. .put( '/lock', - protect('waitlist.lock'), + protect(Permissions.WaitlistLock), schema(validations.lockWaitlist), route(controller.lockWaitlist), ); diff --git a/src/schema.sql b/src/schema.sql new file mode 100644 index 00000000..75c7cf6e --- /dev/null +++ b/src/schema.sql @@ -0,0 +1,58 @@ +CREATE TABLE acl_roles ( + id UUID PRIMARY KEY, +); + +CREATE TABLE media ( + id UUID PRIMARY KEY, + source_id TEXT NOT NULL, + source_type TEXT NOT NULL, + source_data JSONB DEFAULT NULL, + artist TEXT NOT NULL, + title TEXT NOT NULL, + duration INTEGER NOT NULL, + thumbnail URL NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + + UNIQUE KEY source_key(source_id, source_type) +); + +CREATE TABLE users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + active_playlist_id UUID DEFAULT NULL REFERENCES playlists(id), + pending_activation BOOLEAN DEFAULT false, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, +); + +CREATE TABLE playlists ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id), + name TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, +); + +CREATE TABLE playlist_items ( + id UUID PRIMARY KEY, + playlist_id UUID REFERENCES playlists(id), + media_id UUID REFERENCES media(id), + artist TEXT NOT NULL, + title TEXT NOT NULL, + start INTEGER NOT NULL, + end INTEGER NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, +); + +CREATE TABLE history_entries ( + id UUID PRIMARY KEY, + media_id UUID REFERENCES media(id), + artist TEXT NOT NULL, + title TEXT NOT NULL, + start INTEGER NOT NULL, + end INTEGER NOT NULL, + created_at DATETIME NOT NULL +); diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 00000000..53d80423 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,157 @@ +import type { Kysely as KyselyBase, Generated } from 'kysely'; +import type { JsonObject, Tagged } from 'type-fest'; // eslint-disable-line n/no-missing-import, n/no-unpublished-import + +export type UserID = Tagged; +export type MediaID = Tagged; +export type PlaylistID = Tagged; +export type PlaylistItemID = Tagged; +export type HistoryEntryID = Tagged; +export type Permission = Tagged; + +type Selected = { + [K in keyof T]: T[K] extends Generated ? Inner : T[K]; +} & {}; + +export type Media = Selected; +export interface MediaTable { + id: Generated, + sourceID: string, + sourceType: string, + sourceData: JsonObject | null, + artist: string, + title: string, + duration: number, + thumbnail: string, + createdAt: Generated, + updatedAt: Generated, +} + +export type User = Selected>; +export interface UserTable { + id: Generated, + username: string, + email: string | null, + password: string | null, + slug: string, + avatar: string | null, + activePlaylistID: PlaylistID | null, + pendingActivation: boolean, + createdAt: Generated, + updatedAt: Generated, +} + +export interface UserRoleTable { + userID: UserID, + role: string, +} + +export interface RoleTable { + id: string, + permissions: Permission[], +} + +export type Ban = Selected; +export interface BanTable { + userID: UserID, + moderatorID: UserID, + expiresAt: Date | null, + reason: string | null, + createdAt: Generated, + updatedAt: Generated, +} + +export type Mute = Selected; +export interface MuteTable { + userID: UserID, + moderatorID: UserID, + expiresAt: Date, + createdAt: Generated, + updatedAt: Generated, +} + +export type AuthService = Selected; +export interface AuthServiceTable { + userID: UserID, + service: string, + serviceID: string, + serviceAvatar: string | null, + createdAt: Generated, + updatedAt: Generated, +} + +export type Playlist = Selected>; +export type PlaylistWithItems = Selected; +export interface PlaylistTable { + id: Generated, + userID: UserID, + name: string, + items: PlaylistItemID[], + createdAt: Generated, + updatedAt: Generated, +} + +export type PlaylistItem = Selected; +export interface PlaylistItemTable { + id: Generated, + playlistID: PlaylistID, + mediaID: MediaID, + artist: string, + title: string, + start: number, + end: number, + createdAt: Generated, + updatedAt: Generated, +} + +export type HistoryEntry = Selected; +export interface HistoryEntryTable { + id: Generated, + userID: UserID, + mediaID: MediaID, + /** Snapshot of the media artist name at the time this entry was played. */ + artist: string, + /** Snapshot of the media title at the time this entry was played. */ + title: string, + /** Time to start playback at. */ + start: number, + /** Time to stop playback at. */ + end: number, + /** Arbitrary source-specific data required for media playback. */ + sourceData: JsonObject | null, + createdAt: Generated, +} + +export type Feedback = Selected; +export interface FeedbackTable { + historyEntryID: HistoryEntryID, + userID: UserID, + vote: Generated<-1 | 0 | 1>, + favorite: Generated<0 | 1>, +} + +export interface ConfigurationTable { + name: string, + value: JsonObject | null, +} + +export interface MigrationTable { + name: string, +} + +export interface Database { + configuration: ConfigurationTable, + migrations: MigrationTable, + media: MediaTable, + users: UserTable, + userRoles: UserRoleTable, + roles: RoleTable, + bans: BanTable, + mutes: MuteTable, + authServices: AuthServiceTable, + playlists: PlaylistTable, + playlistItems: PlaylistItemTable, + historyEntries: HistoryEntryTable, + feedback: FeedbackTable, +} + +export type Kysely = KyselyBase; diff --git a/src/schemas/definitions.json b/src/schemas/definitions.json index de68276d..f376ea12 100644 --- a/src/schemas/definitions.json +++ b/src/schemas/definitions.json @@ -2,9 +2,9 @@ "$schema": "https://json-schema.org/draft/2019-09/schema", "$id": "https://ns.u-wave.net/schemas/definitions.json#", "definitions": { - "ObjectID": { + "UUID": { "type": "string", - "pattern": "^[0-9a-f]{24}$" + "pattern": "^[0-9a-f-]{36}$" }, "Username": { "type": "string", diff --git a/src/sockets/AuthedConnection.js b/src/sockets/AuthedConnection.js index 9e7edcb8..5308237c 100644 --- a/src/sockets/AuthedConnection.js +++ b/src/sockets/AuthedConnection.js @@ -9,7 +9,7 @@ class AuthedConnection extends EventEmitter { /** * @param {import('../Uwave.js').default} uw * @param {import('ws').WebSocket} socket - * @param {import('../models/index.js').User} user + * @param {import('../schema.js').User} user */ constructor(uw, socket, user) { super(); diff --git a/src/sockets/GuestConnection.js b/src/sockets/GuestConnection.js index 211c21d4..2eb81597 100644 --- a/src/sockets/GuestConnection.js +++ b/src/sockets/GuestConnection.js @@ -61,7 +61,7 @@ class GuestConnection extends EventEmitter { } /** - * @param {import('../models/index.js').User} user + * @param {import('../schema.js').User} user */ isReconnect(user) { return this.uw.redis.exists(`http-api:disconnected:${user.id}`); diff --git a/src/sockets/LostConnection.js b/src/sockets/LostConnection.js index 56832a15..c5d67a45 100644 --- a/src/sockets/LostConnection.js +++ b/src/sockets/LostConnection.js @@ -5,7 +5,7 @@ class LostConnection extends EventEmitter { /** * @param {import('../Uwave.js').default} uw - * @param {import('../models/index.js').User} user + * @param {import('../schema.js').User} user */ constructor(uw, user, timeout = 30) { super(); diff --git a/src/types.ts b/src/types.ts index 8ef336f4..761fd3c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,12 @@ /* eslint-disable n/no-missing-import,n/no-unpublished-import */ // This file contains supporting types that can't be expressed in JavaScript/JSDoc. -import type { Model } from 'mongoose'; import type { ParsedQs } from 'qs'; import type { JsonObject } from 'type-fest'; import type { Request as ExpressRequest, Response as ExpressResponse } from 'express'; import type UwaveServer from './Uwave.js'; import type { HttpApi } from './HttpApi.js'; -import type { User as UwaveUser } from './models/index.js'; +import type { UserID, User as UwaveUser } from './schema.js'; import type { AuthenticateOptions } from './controllers/authenticate.js'; // Add üWave specific request properties. @@ -34,11 +33,11 @@ declare global { declare module 'ioredis' { interface Redis { /** Run the add-to-waitlist script, declared in src/plugins/waitlist.js. */ - 'uw:addToWaitlist'(...args: [...keys: string[], userId: string]): Promise; + 'uw:addToWaitlist'(...args: [...keys: string[], userId: UserID]): Promise; /** Run the move-waitlist script, declared in src/plugins/waitlist.js. */ - 'uw:moveWaitlist'(...args: [...keys: string[], userId: string, position: number]): Promise; + 'uw:moveWaitlist'(...args: [...keys: string[], userId: UserID, position: number]): Promise; /** Run the remove-after-current-play script, declared in src/plugins/booth.js. */ - 'uw:removeAfterCurrentPlay'(...args: [...keys: string[], userId: string, remove: boolean]): Promise<0 | 1>; + 'uw:removeAfterCurrentPlay'(...args: [...keys: string[], userId: UserID, remove: boolean]): Promise<0 | 1>; } } @@ -54,7 +53,7 @@ export type Request< TQuery = DefaultQuery, TBody = DefaultBody, > = ExpressRequest & { - user?: UwaveUser, + user?: UwaveUser & { roles: string[] }, }; /** @@ -74,7 +73,7 @@ export type AuthenticatedRequest< TQuery = DefaultQuery, TBody = DefaultBody, > = Request & { - user: UwaveUser, + user: UwaveUser & { roles: string[] }, }; /** @@ -86,12 +85,6 @@ export type AuthenticatedController< TBody = DefaultBody, > = (req: AuthenticatedRequest, res: ExpressResponse) => Promise; -/** - * Utility type that returns a Document given a Model>. - */ -export type ToDocument> = - TModel extends Model ? TDoc : never; - type LegacyPaginationQuery = { page?: string, limit?: string }; type OffsetPaginationQuery = { page?: { offset?: string, limit?: string }, diff --git a/src/utils/Multimap.js b/src/utils/Multimap.js new file mode 100644 index 00000000..b00f0a5c --- /dev/null +++ b/src/utils/Multimap.js @@ -0,0 +1,88 @@ +/** + * A map with multiple values per key. + * + * @template K + * @template T + */ +export default class Multimap { + /** @type {Map} */ + #map = new Map(); + + /** + * Return true if the given key exists in the map. + * + * @param {K} key + */ + has(key) { + return this.#map.has(key); + } + + /** + * Get all values for a key. + * + * @param {K} key + */ + get(key) { + return this.#map.get(key); + } + + /** + * Add a key/value pair. + * + * @param {K} key + * @param {T} value + */ + set(key, value) { + const existing = this.#map.get(key); + if (existing) { + existing.push(value); + } else { + this.#map.set(key, [value]); + } + return this; + } + + /** + * Delete all elements with a given key. Return true if any elements existed. + * + * @param {K} key + */ + delete(key) { + return this.#map.delete(key); + } + + /** + * Remove a specific element with a given key. Return true if it existed. + * + * @param {K} key + * @param {T} value + */ + remove(key, value) { + const existing = this.#map.get(key); + if (!existing) { + return false; + } + + // If this is the only element for the key, delete the whole key, so + // we never have empty keys. + if (existing.length === 1 && existing[0] === value) { + return this.#map.delete(key); + } + + const index = existing.indexOf(value); + if (index === -1) { + return false; + } + existing.splice(index, 1); + return true; + } + + /** Iterate over the keys in the map. */ + keys() { + return this.#map.keys(); + } + + [Symbol.iterator]() { + return this.#map.entries(); + } +} diff --git a/src/utils/beautifyDuplicateKeyError.js b/src/utils/beautifyDuplicateKeyError.js index 5428dafd..73ad8cf7 100644 --- a/src/utils/beautifyDuplicateKeyError.js +++ b/src/utils/beautifyDuplicateKeyError.js @@ -18,7 +18,7 @@ function isDuplicateKeyError(error) { * Turn duplicate key errors from Mongo into useful-for-humans error messages. * * @param {Error} error Error instance that may be a duplicate key error. - * @return {Error} More useful error if a MongoDB duplicate key error was given, + * @returns {Error} More useful error if a MongoDB duplicate key error was given, * otherwise the given error, unchanged. */ function beautifyDuplicateKeyError(error) { diff --git a/src/utils/removeFromWaitlist.js b/src/utils/removeFromWaitlist.js index 27c5172e..fe1b4675 100644 --- a/src/utils/removeFromWaitlist.js +++ b/src/utils/removeFromWaitlist.js @@ -1,22 +1,14 @@ /** * @param {import('../Uwave.js').default} uw - */ -function getWaitingUserIDs(uw) { - return uw.redis.lrange('waitlist', 0, -1); -} - -/** - * @param {import('../Uwave.js').default} uw - * @param {import('mongodb').ObjectId} userID + * @param {import('../schema.js').UserID} userID */ async function removeFromWaitlist(uw, userID) { - const id = userID.toString(); - const waitingIDs = await getWaitingUserIDs(uw); - if (waitingIDs.includes(id)) { - await uw.redis.lrem('waitlist', 0, id); + const waitingIDs = await uw.waitlist.getUserIDs(); + if (waitingIDs.includes(userID)) { + await uw.redis.lrem('waitlist', 0, userID); uw.publish('waitlist:leave', { - userID: id, - waitlist: await getWaitingUserIDs(uw), + userID, + waitlist: await uw.waitlist.getUserIDs(), }); } } diff --git a/src/utils/serialize.js b/src/utils/serialize.js index 2fcff093..4b354839 100644 --- a/src/utils/serialize.js +++ b/src/utils/serialize.js @@ -1,36 +1,89 @@ /** - * @typedef {import('../models/index.js').User} User - * @typedef {import('../models/index.js').Playlist} Playlist - * @typedef {import('../models/Playlist.js').LeanPlaylist} LeanPlaylist - */ - -/** - * @param {Playlist | LeanPlaylist} model + * @param {import('../schema.js').Playlist & { size: number }} model */ export function serializePlaylist(model) { return { - _id: 'id' in model ? model.id : model._id.toString(), + _id: model.id, name: model.name, - author: model.author.toString(), + author: model.userID, createdAt: model.createdAt.toISOString(), - description: model.description, - size: model.media.length, + description: '', + size: model.size, + }; +} + +/** + * @param {{ + * id: import('../schema.js').MediaID, + * sourceType: string, + * sourceID: string, + * sourceData?: import('type-fest').JsonObject | null, + * artist: string, + * title: string, + * duration: number, + * thumbnail: string, + * }} model + */ +export function serializeMedia(model) { + return { + _id: model.id, + sourceType: model.sourceType, + sourceID: model.sourceID, + sourceData: model.sourceData, + artist: model.artist, + title: model.title, + duration: model.duration, + thumbnail: model.thumbnail, + }; +} + +/** + * @param {{ + * id: import('../schema.js').PlaylistItemID, + * media: import('../schema.js').Media, + * artist: string, + * title: string, + * start: number, + * end: number, + * createdAt?: Date, + * updatedAt?: Date, + * }} model + */ +export function serializePlaylistItem(model) { + return { + _id: model.id, + media: serializeMedia(model.media), + artist: model.artist, + title: model.title, + start: model.start, + end: model.end, + createdAt: model.createdAt?.toISOString(), + updatedAt: model.updatedAt?.toISOString(), }; } /** - * @param {Pick} model + * @param {{ + * id: import('../schema.js').UserID, + * username: string, + * slug: string, + * roles: string[], + * avatar: string | null, + * activePlaylistID?: string | null, + * createdAt: Date, + * updatedAt?: Date, + * }} model */ export function serializeUser(model) { return { - _id: model._id.toString(), + _id: model.id, username: model.username, slug: model.slug, roles: model.roles, avatar: model.avatar, - createdAt: model.createdAt.toISOString(), - updatedAt: model.updatedAt.toISOString(), - lastSeenAt: model.lastSeenAt.toISOString(), + activePlaylist: model.activePlaylistID ?? null, + createdAt: model.createdAt.toISOString() ?? null, + updatedAt: model.updatedAt?.toISOString() ?? null, + // lastSeenAt: model.lastSeenAt?.toISOString(), }; } diff --git a/src/utils/skipIfCurrentDJ.js b/src/utils/skipIfCurrentDJ.js index 797fba45..c7d3e0c7 100644 --- a/src/utils/skipIfCurrentDJ.js +++ b/src/utils/skipIfCurrentDJ.js @@ -7,7 +7,7 @@ function getCurrentDJ(uw) { /** * @param {import('../Uwave.js').default} uw - * @param {import('mongodb').ObjectId} userID + * @param {import('../schema.js').UserID} userID */ async function skipIfCurrentDJ(uw, userID) { const currentDJ = await getCurrentDJ(uw); diff --git a/src/utils/sqlite.js b/src/utils/sqlite.js new file mode 100644 index 00000000..b8114fe7 --- /dev/null +++ b/src/utils/sqlite.js @@ -0,0 +1,190 @@ +import lodash from 'lodash'; +import { sql, OperationNodeTransformer } from 'kysely'; + +/** + * @template {unknown[]} T + * @param {import('kysely').Expression} expr + * @returns {import('kysely').RawBuilder<{ + * key: unknown, + * value: T[0], + * type: string, + * atom: T[0], + * id: number, + * parent: number, + * fullkey: string, + * path: string, + * }>} + */ +export function jsonEach(expr) { + return sql`json_each(${expr})`; +} + +/** + * @template {unknown[]} T + * @param {import('kysely').Expression} expr + * @returns {import('kysely').RawBuilder} + */ +export function jsonLength(expr) { + return sql`json_array_length(${expr})`; +} + +/** + * @param {import('type-fest').Jsonifiable} value + * @returns {import('kysely').RawBuilder} + */ +export function jsonb(value) { + return sql`jsonb(${JSON.stringify(value)})`; +} + +/** + * @template {unknown[]} T + * @param {import('kysely').Expression} expr + * @returns {import('kysely').RawBuilder} + */ +export function json(expr) { + return sql`json(${expr})`; +} + +/** + * @template {unknown[]} T + * @param {import('kysely').Expression} expr + * @returns {import('kysely').RawBuilder} + */ +export function arrayShuffle(expr) { + return sql`jsonb(json_array_shuffle(${json(expr)}))`; +} + +/** + * @template {unknown[]} T + * @param {import('kysely').Expression} expr + * @returns {import('kysely').RawBuilder} + */ +export function arrayCycle(expr) { + return sql` + (CASE ${jsonLength(expr)} + WHEN 0 THEN (${expr}) + ELSE jsonb_insert( + jsonb_remove((${expr}), '$[0]'), + '$[#]', + (${expr})->>0 + ) + END) + `; +} + +/** + * @template {unknown} T + * @param {import('kysely').Expression} expr + * @returns {import('kysely').RawBuilder} + */ +export function jsonGroupArray(expr) { + return sql`json_group_array(${expr})`; +} + +/** @type {import('kysely').RawBuilder} */ +export const now = sql`(strftime('%FT%TZ', 'now'))`; + +/** Stringify dates before entering them in the database. */ +class SqliteDateTransformer extends OperationNodeTransformer { + /** @param {import('kysely').ValueNode} node */ + transformValue(node) { + if (node.value instanceof Date) { + return { ...node, value: node.value.toISOString() }; + } + return node; + } + + /** @param {import('kysely').PrimitiveValueListNode} node */ + transformPrimitiveValueList(node) { + return { + ...node, + values: node.values.map((value) => { + if (value instanceof Date) { + return value.toISOString(); + } + return value; + }), + }; + } + + /** @param {import('kysely').ColumnUpdateNode} node */ + transformColumnUpdate(node) { + /** + * @param {import('kysely').OperationNode} node + * @returns {node is import('kysely').ValueNode} + */ + function isValueNode(node) { + return node.kind === 'ValueNode'; + } + + if (isValueNode(node.value) && node.value.value instanceof Date) { + return super.transformColumnUpdate({ + ...node, + value: /** @type {import('kysely').ValueNode} */ ({ + ...node.value, + value: node.value.value.toISOString(), + }), + }); + } + return super.transformColumnUpdate(node); + } +} + +export class SqliteDateColumnsPlugin { + /** @param {string[]} dateColumns */ + constructor(dateColumns) { + this.dateColumns = new Set(dateColumns); + this.transformer = new SqliteDateTransformer(); + } + + /** @param {import('kysely').PluginTransformQueryArgs} args */ + transformQuery(args) { + return this.transformer.transformNode(args.node); + } + + /** @param {string} col */ + #isDateColumn(col) { + if (this.dateColumns.has(col)) { + return true; + } + const i = col.lastIndexOf('.'); + return i !== -1 && this.dateColumns.has(col.slice(i)); + } + + /** @param {import('kysely').PluginTransformResultArgs} args */ + async transformResult(args) { + for (const row of args.result.rows) { + for (let col in row) { // eslint-disable-line no-restricted-syntax + if (Object.hasOwn(row, col) && this.#isDateColumn(col)) { + const value = row[col]; + if (typeof value === 'string') { + row[col] = new Date(value); + } + } + } + } + return args.result; + } +} + +/** + * @param {string} path + * @returns {Promise} + */ +export async function connect(path) { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(path ?? 'uwave_local.sqlite'); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.function('json_array_shuffle', { directOnly: true }, (items) => { + if (typeof items !== 'string') { + throw new TypeError('json_array_shuffle(): items must be JSON string'); + } + const array = JSON.parse(items); + if (!Array.isArray(array)) { + throw new TypeError('json_array_shuffle(): items must be JSON array'); + } + return JSON.stringify(lodash.shuffle(array)); + }); + return db; +} diff --git a/src/utils/toListResponse.js b/src/utils/toListResponse.js index 4becb676..3700fdc4 100644 --- a/src/utils/toListResponse.js +++ b/src/utils/toListResponse.js @@ -11,6 +11,11 @@ const { * @typedef {Record} IncludedOptions */ +/** @param {{ _id: string, id?: undefined } | { _id?: undefined, id: string }} d */ +function getID(d) { + return d._id ?? d.id; +} + /** * @param {any[]} data * @param {IncludedOptions} included @@ -38,9 +43,9 @@ function extractIncluded(data, included) { * @param {{ _id: string }} item */ function include(type, item) { - if (!had.has(type + item._id)) { + if (!had.has(type + getID(item))) { includeds[type].push(item); - had.add(type + item._id); + had.add(type + getID(item)); } } @@ -56,10 +61,10 @@ function extractIncluded(data, included) { item = cloneDeep(item); } if (Array.isArray(includedItem)) { - setPath(item, path, includedItem.map((i) => i._id)); + setPath(item, path, includedItem.map(getID)); includedItem.forEach((i) => include(type, i)); } else { - setPath(item, path, includedItem._id); + setPath(item, path, getID(includedItem)); include(type, includedItem); } } diff --git a/src/validations.js b/src/validations.js index b47171a9..5c6df29c 100644 --- a/src/validations.js +++ b/src/validations.js @@ -128,7 +128,7 @@ export const skipBooth = /** @type {const} */ ({ type: 'object', properties: { reason: { type: 'string' }, - userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, remove: { type: 'boolean', default: false }, }, dependentRequired: { @@ -142,7 +142,7 @@ export const leaveBooth = /** @type {const} */ ({ body: { type: 'object', properties: { - userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, autoLeave: { type: 'boolean', default: true }, }, }, @@ -152,7 +152,7 @@ export const replaceBooth = /** @type {const} */ ({ body: { type: 'object', properties: { - userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['userID'], }, @@ -162,7 +162,7 @@ export const getVote = /** @type {const} */ ({ params: { type: 'object', properties: { - historyID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + historyID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['historyID'], }, @@ -172,7 +172,7 @@ export const vote = /** @type {const} */ ({ params: { type: 'object', properties: { - historyID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + historyID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['historyID'], }, @@ -189,8 +189,8 @@ export const favorite = /** @type {const} */ ({ body: { type: 'object', properties: { - playlistID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, - historyID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + playlistID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, + historyID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['playlistID', 'historyID'], }, @@ -211,7 +211,7 @@ export const getRoomHistory = /** @type {const} */ ({ filter: { type: 'object', properties: { - media: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + media: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, }, }, @@ -226,7 +226,7 @@ export const deleteChatByUser = /** @type {const} */ ({ params: { type: 'object', properties: { - id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['id'], }, @@ -260,7 +260,7 @@ export const setMotd = /** @type {const} */ ({ const playlistParams = /** @type {const} */ ({ type: 'object', properties: { - id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['id'], }); @@ -268,8 +268,8 @@ const playlistParams = /** @type {const} */ ({ const playlistItemParams = /** @type {const} */ ({ type: 'object', properties: { - id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, - itemID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, + itemID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['id', 'itemID'], }); @@ -278,7 +278,7 @@ export const getPlaylists = /** @type {const} */ ({ query: { type: 'object', properties: { - contains: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + contains: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, }, }); @@ -369,7 +369,7 @@ export const addPlaylistItems = /** @type {const} */ ({ properties: { after: { oneOf: [ - { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, { const: null }, { const: -1 }, ], @@ -395,7 +395,7 @@ export const removePlaylistItems = /** @type {const} */ ({ properties: { items: { type: 'array', - items: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + items: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, }, required: ['items'], @@ -409,7 +409,7 @@ export const movePlaylistItems = /** @type {const} */ ({ properties: { items: { type: 'array', - items: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + items: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, }, required: ['items'], @@ -420,7 +420,7 @@ export const movePlaylistItems = /** @type {const} */ ({ properties: { after: { oneOf: [ - { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, { const: null }, { const: -1 }, ], @@ -499,7 +499,7 @@ export const search = /** @type {const} */ ({ const userParams = /** @type {const} */ ({ type: 'object', properties: { - id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['id'], }); @@ -527,7 +527,7 @@ export const addUserRole = /** @type {const} */ ({ params: { type: 'object', properties: { - id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, role: { type: 'string' }, }, required: ['id', 'role'], @@ -538,7 +538,7 @@ export const removeUserRole = /** @type {const} */ ({ params: { type: 'object', properties: { - id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + id: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, role: { type: 'string' }, }, required: ['id', 'role'], @@ -595,7 +595,7 @@ export const joinWaitlist = /** @type {const} */ ({ body: { type: 'object', properties: { - userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, }, required: ['userID'], }, @@ -605,7 +605,7 @@ export const moveWaitlist = /** @type {const} */ ({ body: { type: 'object', properties: { - userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' }, + userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/UUID' }, position: { type: 'integer', minimum: 0, diff --git a/test/acl.mjs b/test/acl.mjs index acc99d79..07a36d47 100644 --- a/test/acl.mjs +++ b/test/acl.mjs @@ -10,14 +10,14 @@ describe('ACL', () => { uw = await createUwave('acl'); user = await uw.test.createUser(); - await uw.acl.createRole('test.role', []); + await uw.acl.createRole('testRole', ['test.perm']); }); afterEach(async () => { await uw.destroy(); }); it('can check if a user is not allowed to do something', async () => { - assert.strictEqual(await uw.acl.isAllowed(user, 'test.role'), false); + assert.strictEqual(await uw.acl.isAllowed(user, 'test.perm'), false); }); it('disallows nonexistent roles by default', async () => { @@ -25,52 +25,53 @@ describe('ACL', () => { }); it('can allow users to do things', async () => { - assert.strictEqual(await uw.acl.isAllowed(user, 'test.role'), false); + assert.strictEqual(await uw.acl.isAllowed(user, 'test.perm'), false); - await uw.acl.allow(user, ['test.role']); - assert.strictEqual(await uw.acl.isAllowed(user, 'test.role'), true); + await uw.acl.allow(user, ['testRole']); + assert.strictEqual(await uw.acl.isAllowed(user, 'test.perm'), true); }); - it('can create new roles, grouping existing roles', async () => { - await uw.acl.createRole('group.of.roles', [ - 'test.role', + it('can create new roles, grouping existing permissions', async () => { + await uw.acl.createRole('groupOfPermissions', [ + 'test.perm', 'some.other.role', 'universe.destroy', 'universe.create', ]); - await uw.acl.createRole('other.group.of.roles', [ + await uw.acl.createRole('otherGroupOfPermissions', [ 'strawberry.eat', ]); - await uw.acl.allow(user, ['group.of.roles']); + await uw.acl.allow(user, ['groupOfPermissions']); assert.strictEqual(await uw.acl.isAllowed(user, 'universe.create'), true); }); it('can remove permissions from users', async () => { - await uw.acl.allow(user, ['test.role']); - assert.strictEqual(await uw.acl.isAllowed(user, 'test.role'), true); + await uw.acl.allow(user, ['testRole']); + assert.strictEqual(await uw.acl.isAllowed(user, 'test.perm'), true); - await uw.acl.disallow(user, ['test.role']); - assert.strictEqual(await uw.acl.isAllowed(user, 'test.role'), false); + await uw.acl.disallow(user, ['testRole']); + assert.strictEqual(await uw.acl.isAllowed(user, 'test.perm'), false); }); it('can delete roles', async () => { - await uw.acl.createRole('test.role', []); - assert(Object.keys(await uw.acl.getAllRoles()).includes('test.role')); - await uw.acl.deleteRole('test.role'); - assert(!Object.keys(await uw.acl.getAllRoles()).includes('test.role')); + await uw.acl.createRole('tempRole', []); + assert(Object.keys(await uw.acl.getAllRoles()).includes('tempRole')); + await uw.acl.deleteRole('tempRole'); + assert(!Object.keys(await uw.acl.getAllRoles()).includes('tempRole')); }); describe('GET /roles', () => { it('lists available roles', async () => { - await uw.acl.createRole('test.role', ['test.permission', 'test.permission2']); + await uw.acl.createRole('testRole2', ['test.permission', 'test.permission2']); const res = await supertest(uw.server) .get('/api/roles') .expect(200); sinon.assert.match(res.body.data, { - 'test.role': ['test.permission', 'test.permission2'], + testRole: ['test.perm'], + testRole2: ['test.permission', 'test.permission2'], }); }); }); @@ -78,7 +79,7 @@ describe('ACL', () => { describe('PUT /roles/:name', () => { it('requires authentication', async () => { await supertest(uw.server) - .put('/api/roles/test.role') + .put('/api/roles/testRole') .send({ permissions: ['test.permission', 'test.permission2'], }) @@ -89,17 +90,18 @@ describe('ACL', () => { const token = await uw.test.createTestSessionToken(user); await supertest(uw.server) - .put('/api/roles/test.role') + .put('/api/roles/newRole') .set('Cookie', `uwsession=${token}`) .send({ permissions: ['test.permission', 'test.permission2'], }) .expect(403); - await uw.acl.allow(user, ['acl.create']); + await uw.acl.createRole('roleAuthor', ['acl.create']); + await uw.acl.allow(user, ['roleAuthor']); await supertest(uw.server) - .put('/api/roles/test.role') + .put('/api/roles/newRole') .set('Cookie', `uwsession=${token}`) .send({ permissions: ['test.permission', 'test.permission2'], @@ -109,10 +111,11 @@ describe('ACL', () => { it('validates input', async () => { const token = await uw.test.createTestSessionToken(user); - await uw.acl.allow(user, ['acl.create']); + await uw.acl.createRole('roleAuthor', ['acl.create']); + await uw.acl.allow(user, ['roleAuthor']); let res = await supertest(uw.server) - .put('/api/roles/test.role') + .put('/api/roles/newRole') .set('Cookie', `uwsession=${token}`) .send({}) .expect(400); @@ -122,7 +125,7 @@ describe('ACL', () => { }); res = await supertest(uw.server) - .put('/api/roles/test.role') + .put('/api/roles/newRole') .set('Cookie', `uwsession=${token}`) .send({ permissions: 'not an array' }) .expect(400); @@ -132,7 +135,7 @@ describe('ACL', () => { }); res = await supertest(uw.server) - .put('/api/roles/test.role') + .put('/api/roles/newRole') .set('Cookie', `uwsession=${token}`) .send({ permissions: [{ not: 'a' }, 'string'] }) .expect(400); @@ -144,10 +147,11 @@ describe('ACL', () => { it('creates a role', async () => { const token = await uw.test.createTestSessionToken(user); - await uw.acl.allow(user, ['acl.create']); + await uw.acl.createRole('roleAuthor', ['acl.create']); + await uw.acl.allow(user, ['roleAuthor']); const res = await supertest(uw.server) - .put('/api/roles/test.role') + .put('/api/roles/newRole') .set('Cookie', `uwsession=${token}`) .send({ permissions: ['test.permission', 'test.permission2'], @@ -155,7 +159,7 @@ describe('ACL', () => { .expect(201); sinon.assert.match(res.body.data, { - name: 'test.role', + name: 'newRole', permissions: ['test.permission', 'test.permission2'], }); }); @@ -163,27 +167,28 @@ describe('ACL', () => { describe('DELETE /roles/:name', () => { it('requires authentication', async () => { - await uw.acl.createRole('test.role', []); + await uw.acl.createRole('testRole', []); await supertest(uw.server) - .delete('/api/roles/test.role') + .delete('/api/roles/testRole') .expect(401); }); it('requires the acl.delete role', async () => { const token = await uw.test.createTestSessionToken(user); - await uw.acl.createRole('test.role', ['test.permission', 'test.permission2']); + await uw.acl.createRole('testRole', ['test.permission', 'test.permission2']); await supertest(uw.server) - .delete('/api/roles/test.role') + .delete('/api/roles/testRole') .set('Cookie', `uwsession=${token}`) .expect(403); - await uw.acl.allow(user, ['acl.delete']); + await uw.acl.createRole('roleDeleter', ['acl.delete']); + await uw.acl.allow(user, ['roleDeleter']); await supertest(uw.server) - .delete('/api/roles/test.role') + .delete('/api/roles/testRole') .set('Cookie', `uwsession=${token}`) .expect(200); }); @@ -192,23 +197,25 @@ describe('ACL', () => { const moderator = await uw.test.createUser(); const token = await uw.test.createTestSessionToken(moderator); - await uw.acl.createRole('test.role', ['test.permission', 'test.permission2']); - await uw.acl.allow(user, ['test.role']); - await uw.acl.allow(moderator, ['acl.delete']); + await uw.acl.createRole('testRole', ['test.permission', 'test.permission2']); + await uw.acl.createRole('roleDeleter', ['acl.delete']); - assert(await uw.acl.isAllowed(user, 'test.role')); + await uw.acl.allow(user, ['testRole']); + await uw.acl.allow(moderator, ['roleDeleter']); + + assert(await uw.acl.isAllowed(user, 'test.permission2')); await supertest(uw.server) - .delete('/api/roles/test.role') + .delete('/api/roles/testRole') .set('Cookie', `uwsession=${token}`) .expect(200); const res = await supertest(uw.server) .get('/api/roles') .expect(200); - assert(!Object.keys(res.body.data).includes('test.role')); + assert(!Object.keys(res.body.data).includes('testRole')); - assert(!await uw.acl.isAllowed(user, 'test.role')); + assert(!await uw.acl.isAllowed(user, 'test.permission2')); }); }); }); diff --git a/test/authenticate.mjs b/test/authenticate.mjs index 62590902..8705fce3 100644 --- a/test/authenticate.mjs +++ b/test/authenticate.mjs @@ -39,7 +39,8 @@ describe('Authentication', () => { sinon.assert.match(res.body.data, { _id: user.id, username: user.username, - avatar: user.avatar, + // TODO: sql avatars + // avatar: user.avatar, slug: user.slug, }); }); @@ -193,20 +194,15 @@ describe('Password Reset', () => { }); const user = await uw.test.createUser(); - await uw.models.Authentication.create({ - email: 'test@example.com', - user, - hash: 'passwordhash', - }); await supertest(uw.server) .post('/api/auth/password/reset') - .send({ email: 'test@example.com' }) + .send({ email: user.email }) .expect(200); sinon.assert.calledWithMatch(sendSpy, { data: { - to: 'test@example.com', + to: sinon.match(/@example.com$/), text: sinon.match(/http:\/\/127\.0\.0\.1:\d+\/reset\//), }, }); @@ -228,15 +224,10 @@ describe('Password Reset', () => { }); const user = await uw.test.createUser(); - await uw.models.Authentication.create({ - email: 'test@example.com', - user, - hash: 'passwordhash', - }); await supertest(uw.server) .post('/api/auth/password/reset') - .send({ email: 'test@example.com' }) + .send({ email: user.email }) .expect(200); sinon.assert.calledWithMatch(sendSpy, { diff --git a/test/bans.mjs b/test/bans.mjs index 72b89bc1..3baa8812 100644 --- a/test/bans.mjs +++ b/test/bans.mjs @@ -20,13 +20,8 @@ describe('Bans', () => { assert.strictEqual(await uw.bans.isBanned(user), false); }); it('returns true for banned users', async () => { - user.banned = { - duration: 1000, - expiresAt: Date.now() + 1000, - }; - await user.save(); - // refresh user data - user = await uw.users.getUser(user.id); + const moderator = await uw.test.createUser(); + await uw.bans.ban(user, { moderator, permanent: true, duration: 0 }); assert.strictEqual(await uw.bans.isBanned(user), true); }); }); @@ -39,13 +34,9 @@ describe('Bans', () => { moderator, duration: ms('10 hours'), }); - // refresh user data - user = await uw.users.getUser(user.id); assert.strictEqual(await uw.bans.isBanned(user), true); - await uw.bans.unban(user, { moderator }); - // refresh user data - user = await uw.users.getUser(user.id); + await uw.bans.unban(user.id, { moderator }); assert.strictEqual(await uw.bans.isBanned(user), false); }); }); @@ -59,13 +50,14 @@ describe('Bans', () => { it('requires the users.bans.list role', async () => { const token = await uw.test.createTestSessionToken(user); + await uw.acl.createRole('testBans', ['users.bans.list']); await supertest(uw.server) .get('/api/bans') .set('Cookie', `uwsession=${token}`) .expect(403); - await uw.acl.allow(user, ['users.bans.list']); + await uw.acl.allow(user, ['testBans']); await supertest(uw.server) .get('/api/bans') @@ -75,7 +67,8 @@ describe('Bans', () => { it('returns bans', async () => { const token = await uw.test.createTestSessionToken(user); - await uw.acl.allow(user, ['users.bans.list', 'users.bans.add']); + await uw.acl.createRole('testBans', ['users.bans.list', 'users.bans.add']); + await uw.acl.allow(user, ['testBans']); const bannedUser = await uw.test.createUser(); await uw.bans.ban(bannedUser, { @@ -91,7 +84,7 @@ describe('Bans', () => { assert.strictEqual(res.body.meta.results, 1); sinon.assert.match(res.body.data[0], { - duration: 36000000, + duration: ms('10 hours'), expiresAt: sinon.match.string, reason: 'just to test', moderator: user.id, diff --git a/test/booth.mjs b/test/booth.mjs index 9f9db0b4..a6757d49 100644 --- a/test/booth.mjs +++ b/test/booth.mjs @@ -2,6 +2,7 @@ import assert from 'assert'; import delay from 'delay'; import supertest from 'supertest'; import createUwave from './utils/createUwave.mjs'; +import testSource from './utils/testSource.mjs'; describe('Booth', () => { describe('PUT /booth/:historyID/vote', () => { @@ -13,11 +14,11 @@ describe('Booth', () => { await uw.destroy(); }); - const historyID = '602907622d46ab05a89449f3'; + const unknownHistoryID = '7e8c3ef1-6670-4b52-b334-0c93df924507'; it('requires authentication', async () => { await supertest(uw.server) - .put(`/api/booth/${historyID}/vote`) + .put(`/api/booth/${unknownHistoryID}/vote`) .send({ direction: 1 }) .expect(401); }); @@ -27,13 +28,13 @@ describe('Booth', () => { const token = await uw.test.createTestSessionToken(user); await supertest(uw.server) - .put(`/api/booth/${historyID}/vote`) + .put(`/api/booth/${unknownHistoryID}/vote`) .set('Cookie', `uwsession=${token}`) .send({ direction: 'not a number' }) .expect(400); await supertest(uw.server) - .put(`/api/booth/${historyID}/vote`) + .put(`/api/booth/${unknownHistoryID}/vote`) .set('Cookie', `uwsession=${token}`) .send({ direction: 0 }) .expect(400); @@ -41,21 +42,26 @@ describe('Booth', () => { // These inputs are formatted correctly, but we still expect a 412 because // the history ID does not exist. await supertest(uw.server) - .put(`/api/booth/${historyID}/vote`) + .put(`/api/booth/${unknownHistoryID}/vote`) .set('Cookie', `uwsession=${token}`) .send({ direction: 1 }) .expect(412); await supertest(uw.server) - .put(`/api/booth/${historyID}/vote`) + .put(`/api/booth/${unknownHistoryID}/vote`) .set('Cookie', `uwsession=${token}`) .send({ direction: -1 }) .expect(412); }); it('broadcasts votes', async () => { + uw.source(testSource); + const dj = await uw.test.createUser(); const user = await uw.test.createUser(); + + await uw.acl.allow(dj, ['user']); + const token = await uw.test.createTestSessionToken(user); const ws = await uw.test.connectToWebSocketAs(user); const receivedMessages = []; @@ -63,9 +69,27 @@ describe('Booth', () => { receivedMessages.push(JSON.parse(isBinary ? data.toString() : data)); }); - // Pretend that a DJ exists - await uw.redis.set('booth:currentDJ', dj.id); - await uw.redis.set('booth:historyID', historyID); + // Prep the DJ account to be able to join the waitlist + const { playlist } = await uw.playlists.createPlaylist(dj, { name: 'vote' }); + { + const item = await uw.source('test-source').getOne(dj, 'FOR_VOTE'); + await uw.playlists.addPlaylistItems(playlist, [item]); + } + + const djWs = await uw.test.connectToWebSocketAs(dj); + { + const djToken = await uw.test.createTestSessionToken(dj); + await supertest(uw.server) + .post('/api/waitlist') + .set('Cookie', `uwsession=${djToken}`) + .send({ userID: dj.id }) + .expect(200); + } + + const { body } = await supertest(uw.server) + .get('/api/now') + .expect(200); + const { historyID } = body.booth; await supertest(uw.server) .put(`/api/booth/${historyID}/vote`) @@ -76,6 +100,7 @@ describe('Booth', () => { assert(receivedMessages.some((message) => message.command === 'vote' && message.data.value === -1)); + // Resubmit vote without changing receivedMessages.length = 0; await supertest(uw.server) .put(`/api/booth/${historyID}/vote`) @@ -97,6 +122,8 @@ describe('Booth', () => { await delay(200); assert(receivedMessages.some((message) => message.command === 'vote' && message.data.value === 1)); + + djWs.close(); }); }); }); diff --git a/test/now.mjs b/test/now.mjs index 4d5617d7..af29f9c9 100644 --- a/test/now.mjs +++ b/test/now.mjs @@ -27,7 +27,7 @@ describe('Now', () => { .set('Cookie', `uwsession=${token}`) .expect(200); - assert.strictEqual(res.body.user._id, user._id.toString()); + assert.strictEqual(res.body.user._id, user.id); assert.strictEqual(res.body.activePlaylist, null); }); diff --git a/test/playlists.mjs b/test/playlists.mjs index a491d073..96ea8a23 100644 --- a/test/playlists.mjs +++ b/test/playlists.mjs @@ -4,6 +4,7 @@ import supertest from 'supertest'; import * as sinon from 'sinon'; import randomString from 'random-string'; import createUwave from './utils/createUwave.mjs'; +import testSource from './utils/testSource.mjs'; function assertItemsAndIncludedMedia(body) { for (const item of body.data) { @@ -39,21 +40,7 @@ describe('Playlists', () => { uw = await createUwave('acl'); user = await uw.test.createUser(); - uw.source({ - name: 'test-source', - api: 2, - async get(context, ids) { - return ids.map((id) => ({ - sourceID: id, - artist: `artist ${id}`, - title: `title ${id}`, - duration: 60, - })); - }, - async search() { - throw new Error('unimplemented'); - }, - }); + uw.source(testSource); }); afterEach(async () => { await uw.destroy(); @@ -179,7 +166,7 @@ describe('Playlists', () => { describe('PATCH /playlists/:id', () => { it('requires authentication', async () => { - const fakeID = '603e43b12d46ab05a8946a23'; + const fakeID = 'e2c85d94-344b-4c2a-86bd-95edb939f3e6'; await supertest(uw.server) .patch(`/api/playlists/${fakeID}`) @@ -187,7 +174,7 @@ describe('Playlists', () => { }); it('validates input', async () => { - const fakeID = '603e43b12d46ab05a8946a23'; + const fakeID = 'e2c85d94-344b-4c2a-86bd-95edb939f3e6'; const token = await uw.test.createTestSessionToken(user); await supertest(uw.server) @@ -255,7 +242,7 @@ describe('Playlists', () => { describe('PUT /playlists/:id/rename', () => { it('requires authentication', async () => { - const fakeID = '603e43b12d46ab05a8946a23'; + const fakeID = 'e2c85d94-344b-4c2a-86bd-95edb939f3e6'; await supertest(uw.server) .put(`/api/playlists/${fakeID}/rename`) @@ -263,7 +250,7 @@ describe('Playlists', () => { }); it('validates input', async () => { - const fakeID = '603e43b12d46ab05a8946a23'; + const fakeID = 'e2c85d94-344b-4c2a-86bd-95edb939f3e6'; const token = await uw.test.createTestSessionToken(user); await supertest(uw.server) @@ -325,7 +312,7 @@ describe('Playlists', () => { describe('PUT /playlists/:id/activate', () => { it('requires authentication', async () => { - const fakeID = '603e43b12d46ab05a8946a23'; + const fakeID = 'e2c85d94-344b-4c2a-86bd-95edb939f3e6'; await supertest(uw.server) .put(`/api/playlists/${fakeID}/activate`) @@ -367,7 +354,7 @@ describe('Playlists', () => { describe('GET /playlists/:id/media', () => { let playlist; beforeEach(async () => { - playlist = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' }); + ({ playlist } = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' })); const items = await generateItems(200); await uw.playlists.addPlaylistItems(playlist, items); }); @@ -500,7 +487,7 @@ describe('Playlists', () => { describe('POST /playlists/:id/media', () => { let playlist; beforeEach(async () => { - playlist = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' }); + ({ playlist } = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' })); }); it('requires authentication', async () => { @@ -646,7 +633,7 @@ describe('Playlists', () => { const res2 = await supertest(uw.server) .post(`/api/playlists/${playlist.id}/media`) .set('Cookie', `uwsession=${token}`) - .send({ items: insertItems, after: middleItem._id }) + .send({ items: insertItems, after: middleItem.id }) .expect(200); sinon.assert.match(res2.body.meta, { @@ -662,7 +649,7 @@ describe('Playlists', () => { let playlist; let playlistItems; beforeEach(async () => { - playlist = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' }); + ({ playlist } = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' })); const insertItems = await generateItems(20); const { added } = await uw.playlists.addPlaylistItems(playlist, insertItems, { at: 'start' }); playlistItems = added; @@ -774,9 +761,9 @@ describe('Playlists', () => { const realItems = playlistItems.slice(15).map((item) => item.id); const itemsToMove = [ - '604cee7e2d46ab05a8947ce2', + 'c41f017d-d2a7-494f-8818-ebc0d02fa935', ...realItems, - '56fb09bd2268cb6678186df3', + 'c7b20249-414f-4b6e-838c-d8588b10ab98', ]; await supertest(uw.server) @@ -800,7 +787,7 @@ describe('Playlists', () => { describe('POST /playlists/:id/shuffle', () => { let playlist; beforeEach(async () => { - playlist = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' }); + ({ playlist } = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' })); const insertItems = await generateItems(20); await uw.playlists.addPlaylistItems(playlist, insertItems, { at: 'start' }); }); @@ -837,7 +824,7 @@ describe('Playlists', () => { let playlist; let playlistItems; beforeEach(async () => { - playlist = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' }); + ({ playlist } = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' })); const insertItems = await generateItems(20); const { added } = await uw.playlists.addPlaylistItems(playlist, insertItems, { at: 'start' }); playlistItems = added; @@ -913,9 +900,9 @@ describe('Playlists', () => { const realItems = playlistItems.slice(15).map((item) => item.id); const itemsToRemove = [ - '604cee7e2d46ab05a8947ce2', + 'ee5cf93f-592b-4d17-bc8a-3ef99fd2a37d', ...realItems, - '56fb09bd2268cb6678186df3', + 'a50f21ef-cfbd-484c-bf13-77b3ff0d664c', ]; await supertest(uw.server) diff --git a/test/sources.mjs b/test/sources.mjs index b802fa62..afaceeb6 100644 --- a/test/sources.mjs +++ b/test/sources.mjs @@ -13,19 +13,29 @@ describe('Media Sources', () => { await uw.destroy(); }); + function makeTestMedia(sourceID) { + return { + sourceType: 'test-source', + sourceID, + artist: `artist ${sourceID}`, + title: `title ${sourceID}`, + thumbnail: 'https://placedog.net/280', + }; + } + const testSourceObject = { name: 'test-source', async search(query) { - return [{ sourceID: query }]; + return [makeTestMedia(query)]; }, async get(ids) { - return ids.map((sourceID) => ({ sourceID })); + return ids.map((sourceID) => makeTestMedia(sourceID)); }, }; function testSource() { const search = async (query) => [{ sourceID: query }]; - const get = async (ids) => ids.map((sourceID) => ({ sourceID })); + const get = async (ids) => ids.map((sourceID) => makeTestMedia(sourceID)); return { name: 'test-source', search, @@ -69,8 +79,12 @@ describe('Media Sources', () => { uw.source(testSource); const results = await uw.source('test-source').get(null, ['one', 'two']); assert.deepStrictEqual(results, [ - { sourceType: 'test-source', sourceID: 'one' }, - { sourceType: 'test-source', sourceID: 'two' }, + { + sourceType: 'test-source', sourceID: 'one', artist: 'artist one', title: 'title one', thumbnail: 'https://placedog.net/280', + }, + { + sourceType: 'test-source', sourceID: 'two', artist: 'artist two', title: 'title two', thumbnail: 'https://placedog.net/280', + }, ]); }); diff --git a/test/users.mjs b/test/users.mjs index f5c0ab1c..707688b0 100644 --- a/test/users.mjs +++ b/test/users.mjs @@ -27,7 +27,8 @@ describe('Users', () => { .set('Cookie', `uwsession=${token}`) .expect(403); - await uw.acl.allow(user, ['users.list']); + await uw.acl.createRole('lister', ['users.list']); + await uw.acl.allow(user, ['lister']); await supertest(uw.server) .get('/api/users') diff --git a/test/utils/createUwave.mjs b/test/utils/createUwave.mjs index 193c6a78..2c767955 100644 --- a/test/utils/createUwave.mjs +++ b/test/utils/createUwave.mjs @@ -4,11 +4,8 @@ import { spawn } from 'child_process'; import getPort from 'get-port'; import Redis from 'ioredis'; import uwave from 'u-wave-core'; -import deleteDatabase from './deleteDatabase.mjs'; import testPlugin from './plugin.mjs'; -const DB_HOST = process.env.MONGODB_HOST ?? 'localhost'; - /** * Create a separate in-memory redis instance to run tests against. * This way tests don't interfere with other redises on the system. @@ -64,7 +61,6 @@ async function createUwave(name, options) { const redisServer = process.env.REDIS_URL ? createRedisConnection() : await createIsolatedRedis(); - const mongoUrl = `mongodb://${DB_HOST}/uw_test_${name}`; const port = await getPort(); @@ -72,8 +68,12 @@ async function createUwave(name, options) { ...options, port, redis: redisServer.url, - mongo: mongoUrl, + sqlite: ':memory:', + mongo: null, secret: Buffer.from(`secret_${name}`), + logger: { + level: 'silent', + }, }); uw.use(testPlugin); @@ -83,7 +83,6 @@ async function createUwave(name, options) { await uw.close(); } finally { await redisServer.close(); - await deleteDatabase(mongoUrl); } }; diff --git a/test/utils/deleteDatabase.mjs b/test/utils/deleteDatabase.mjs deleted file mode 100644 index 0ff674f4..00000000 --- a/test/utils/deleteDatabase.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import { once } from 'events'; -import mongoose from 'mongoose'; -import delay from 'delay'; - -const IN_PROGRESS_ERROR = 12586; - -export default async function deleteDatabase(url) { - const mongo = mongoose.createConnection(url); - await once(mongo, 'connected'); - - for (let i = 0; i < 50; i += 1) { - try { - await mongo.dropDatabase(); - break; - } catch (error) { - if (error.code === IN_PROGRESS_ERROR) { - console.log('database op in progress...waiting'); - await delay(100); - } else { - throw error; - } - } - } - - await mongo.close(); -} diff --git a/test/utils/plugin.mjs b/test/utils/plugin.mjs index cc4aa890..9a0cc1d7 100644 --- a/test/utils/plugin.mjs +++ b/test/utils/plugin.mjs @@ -1,22 +1,23 @@ +import { randomUUID } from 'crypto'; import events from 'events'; -import mongoose from 'mongoose'; import jwt from 'jsonwebtoken'; import WebSocket from 'ws'; async function testPlugin(uw) { - const { User } = uw.models; - let i = Date.now(); - async function createUser() { + function createUser() { const props = { - _id: new mongoose.Types.ObjectId(), + id: randomUUID(), username: `test_user_${i.toString(36)}`, slug: i.toString(36), + email: `test${i.toString(36)}@example.com`, + password: 'passwordhash', }; i += 1; - const user = new User(props); - await user.save(); - return user; + return uw.db.insertInto('users') + .values(props) + .returningAll() + .executeTakeFirstOrThrow(); } async function connectToWebSocketAs(user) { diff --git a/test/utils/testSource.mjs b/test/utils/testSource.mjs new file mode 100644 index 00000000..19ad3359 --- /dev/null +++ b/test/utils/testSource.mjs @@ -0,0 +1,16 @@ +export default { + name: 'test-source', + api: 2, + async get(context, ids) { + return ids.map((id) => ({ + sourceID: id, + artist: `artist ${id}`, + title: `title ${id}`, + duration: 60, + thumbnail: 'https://placedog.net/280', + })); + }, + async search() { + throw new Error('unimplemented'); + }, +}; diff --git a/test/waitlist.mjs b/test/waitlist.mjs index 4ec8da62..31a70d53 100644 --- a/test/waitlist.mjs +++ b/test/waitlist.mjs @@ -3,6 +3,7 @@ import supertest from 'supertest'; import * as sinon from 'sinon'; import randomString from 'random-string'; import createUwave from './utils/createUwave.mjs'; +import testSource from './utils/testSource.mjs'; describe('Waitlist', () => { let user; @@ -12,21 +13,7 @@ describe('Waitlist', () => { uw = await createUwave('waitlist'); user = await uw.test.createUser(); - uw.source({ - name: 'test-source', - api: 2, - async get(_context, ids) { - return ids.map((id) => ({ - sourceID: id, - artist: `artist ${id}`, - title: `title ${id}`, - duration: 60, - })); - }, - async search() { - throw new Error('unimplemented'); - }, - }); + uw.source(testSource); }); afterEach(async () => { await uw.destroy(); @@ -37,7 +24,7 @@ describe('Waitlist', () => { } async function createTestPlaylistItem(testUser) { - const playlist = await uw.playlists.createPlaylist(testUser, { name: 'Test Playlist' }); + const { playlist } = await uw.playlists.createPlaylist(testUser, { name: 'Test Playlist' }); await uw.playlists.addPlaylistItems(playlist, [{ sourceType: 'test-source', sourceID: randomString({ length: 10 }), @@ -114,7 +101,7 @@ describe('Waitlist', () => { .expect(403); sinon.assert.match(noPlaylistRes.body.errors[0], { code: 'empty-playlist' }); - const playlist = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' }); + const { playlist } = await uw.playlists.createPlaylist(user, { name: 'Test Playlist' }); const emptyPlaylistRes = await supertest(uw.server) .post('/api/waitlist') @@ -180,7 +167,8 @@ describe('Waitlist', () => { .send({ userID: testSubject.id }) .expect(403); - await uw.acl.allow(user, ['waitlist.add']); + await uw.acl.createRole('adder', ['waitlist.add']); + await uw.acl.allow(user, ['adder']); await supertest(uw.server) .post('/api/waitlist')