From 202ceb02b527830e8bba5f487e546f27c8e6adb3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 10 Aug 2024 15:46:04 -0500 Subject: [PATCH] Update:Auth to use new user model - Express requests include userNew to start migrating API controllers to new user model --- client/components/tables/UsersTable.vue | 4 - client/pages/config/users/index.vue | 9 +- client/store/user.js | 20 +- server/Auth.js | 48 ++- server/Database.js | 7 +- server/Server.js | 18 +- server/SocketAuthority.js | 65 ++-- server/controllers/LibraryController.js | 2 +- server/controllers/MiscController.js | 113 ++++--- server/controllers/UserController.js | 11 +- server/models/MediaProgress.js | 46 +-- server/models/User.js | 260 ++++++++++---- server/objects/settings/EmailSettings.js | 4 +- server/utils/migrations/dbMigration.js | 411 ++++++++++++++--------- 14 files changed, 626 insertions(+), 392 deletions(-) diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index fb11f223..43a84e5d 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -157,10 +157,6 @@ export default { this.init() }, beforeDestroy() { - if (this.$refs.accountModal) { - this.$refs.accountModal.close() - } - if (this.$root.socket) { this.$root.socket.off('user_added', this.addUpdateUser) this.$root.socket.off('user_updated', this.addUpdateUser) diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue index 867d18e4..4dd82591 100644 --- a/client/pages/config/users/index.vue +++ b/client/pages/config/users/index.vue @@ -39,6 +39,11 @@ export default { this.showAccountModal = true } }, - mounted() {} + mounted() {}, + beforeDestroy() { + if (this.$refs.accountModal) { + this.$refs.accountModal.close() + } + } } - \ No newline at end of file + diff --git a/client/store/user.js b/client/store/user.js index 7571f916..10dc8ef6 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -16,7 +16,7 @@ export const state = () => ({ authorSortBy: 'name', authorSortDesc: false, jumpForwardAmount: 10, - jumpBackwardAmount: 10, + jumpBackwardAmount: 10 } }) @@ -26,13 +26,15 @@ export const getters = { getToken: (state) => { return state.user?.token || null }, - getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { - if (!state.user.mediaProgress) return null - return state.user.mediaProgress.find((li) => { - if (episodeId && li.episodeId !== episodeId) return false - return li.libraryItemId == libraryItemId - }) - }, + getUserMediaProgress: + (state) => + (libraryItemId, episodeId = null) => { + if (!state.user.mediaProgress) return null + return state.user.mediaProgress.find((li) => { + if (episodeId && li.episodeId !== episodeId) return false + return li.libraryItemId == libraryItemId + }) + }, getUserBookmarksForItem: (state) => (libraryItemId) => { if (!state.user.bookmarks) return [] return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId) @@ -153,7 +155,7 @@ export const mutations = { }, setUserToken(state, token) { state.user.token = token - localStorage.setItem('token', user.token) + localStorage.setItem('token', token) }, updateMediaProgress(state, { id, data }) { if (!state.user) return diff --git a/server/Auth.js b/server/Auth.js index fd397838..8c0d0991 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -213,8 +213,11 @@ class Auth { return null } - user.authOpenIDSub = userinfo.sub - await Database.userModel.updateFromOld(user) + // Update user with OpenID sub + if (!user.extraData) user.extraData = {} + user.extraData.authOpenIDSub = userinfo.sub + user.changed('extraData', true) + await user.save() Logger.debug(`[Auth] openid: User found by email/username`) return user @@ -788,12 +791,14 @@ class Auth { await Database.updateServerSettings() // New token secret creation added in v2.1.0 so generate new API tokens for each user - const users = await Database.userModel.getOldUsers() + const users = await Database.userModel.findAll({ + attributes: ['id', 'username', 'token'] + }) if (users.length) { for (const user of users) { user.token = await this.generateAccessToken(user) + await user.save({ hooks: false }) } - await Database.updateBulkUsers(users) } } @@ -879,13 +884,13 @@ class Auth { /** * Return the login info payload for a user * - * @param {Object} user + * @param {import('./models/User')} user * @returns {Promise} jsonPayload */ async getUserLoginResponsePayload(user) { const libraryIds = await Database.libraryModel.getAllLibraryIds() return { - user: user.toJSONForBrowser(), + user: user.toOldJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), serverSettings: Database.serverSettings.toJSONForBrowser(), ereaderDevices: Database.emailSettings.getEReaderDevices(user), @@ -907,6 +912,7 @@ class Auth { /** * User changes their password from request + * TODO: Update responses to use error status codes * * @param {import('express').Request} req * @param {import('express').Response} res @@ -941,19 +947,27 @@ class Auth { } } - matchingUser.pash = pw - - const success = await Database.updateUser(matchingUser) - if (success) { - Logger.info(`[Auth] User "${matchingUser.username}" changed password`) - res.json({ - success: true + Database.userModel + .update( + { + pash: pw + }, + { + where: { id: matchingUser.id } + } + ) + .then(() => { + Logger.info(`[Auth] User "${matchingUser.username}" changed password`) + res.json({ + success: true + }) }) - } else { - res.json({ - error: 'Unknown error' + .catch((error) => { + Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) + res.json({ + error: 'Unknown error' + }) }) - } } } diff --git a/server/Database.js b/server/Database.js index ff8b0c7f..2115ac09 100644 --- a/server/Database.js +++ b/server/Database.js @@ -363,7 +363,7 @@ class Database { */ async createRootUser(username, pash, auth) { if (!this.sequelize) return false - await this.models.user.createRootUser(username, pash, auth) + await this.userModel.createRootUser(username, pash, auth) this.hasRootUser = true return true } @@ -390,11 +390,6 @@ class Database { return this.models.user.updateFromOld(oldUser) } - updateBulkUsers(oldUsers) { - if (!this.sequelize) return false - return Promise.all(oldUsers.map((u) => this.updateUser(u))) - } - removeUser(userId) { if (!this.sequelize) return false return this.models.user.removeById(userId) diff --git a/server/Server.js b/server/Server.js index 6188717d..61ad7ab1 100644 --- a/server/Server.js +++ b/server/Server.js @@ -89,9 +89,25 @@ class Server { this.io = null } + /** + * Middleware to check if the current request is authenticated + * req.user is set if authenticated to the OLD user object + * req.userNew is set if authenticated to the NEW user object + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ authMiddleware(req, res, next) { // ask passportjs if the current request is authenticated - this.auth.isAuthenticated(req, res, next) + this.auth.isAuthenticated(req, res, () => { + if (req.user) { + // TODO: req.userNew to become req.user + req.userNew = req.user + req.user = Database.userModel.getOldUser(req.user) + } + next() + }) } cancelLibraryScan(libraryId) { diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 930037a8..af8204c6 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -3,11 +3,20 @@ const Logger = require('./Logger') const Database = require('./Database') const Auth = require('./Auth') +/** + * @typedef SocketClient + * @property {string} id socket id + * @property {SocketIO.Socket} socket + * @property {number} connected_at + * @property {import('./models/User')} user + */ + class SocketAuthority { constructor() { this.Server = null this.io = null + /** @type {Object.} */ this.clients = {} } @@ -18,27 +27,29 @@ class SocketAuthority { */ getUsersOnline() { const onlineUsersMap = {} - Object.values(this.clients).filter(c => c.user).forEach(client => { - if (onlineUsersMap[client.user.id]) { - onlineUsersMap[client.user.id].connections++ - } else { - onlineUsersMap[client.user.id] = { - ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions), - connections: 1 + Object.values(this.clients) + .filter((c) => c.user) + .forEach((client) => { + if (onlineUsersMap[client.user.id]) { + onlineUsersMap[client.user.id].connections++ + } else { + onlineUsersMap[client.user.id] = { + ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions), + connections: 1 + } } - } - }) + }) return Object.values(onlineUsersMap) } getClientsForUser(userId) { - return Object.values(this.clients).filter(c => c.user && c.user.id === userId) + return Object.values(this.clients).filter((c) => c.user?.id === userId) } /** * Emits event to all authorized clients - * @param {string} evt - * @param {any} data + * @param {string} evt + * @param {any} data * @param {Function} [filter] optional filter function to only send event to specific users */ emitter(evt, data, filter = null) { @@ -67,7 +78,7 @@ class SocketAuthority { // Emits event to all admin user clients adminEmitter(evt, data) { for (const socketId in this.clients) { - if (this.clients[socketId].user && this.clients[socketId].user.isAdminOrUp) { + if (this.clients[socketId].user?.isAdminOrUp) { this.clients[socketId].socket.emit(evt, data) } } @@ -75,16 +86,14 @@ class SocketAuthority { /** * Closes the Socket.IO server and disconnect all clients - * - * @param {Function} callback + * + * @param {Function} callback */ close(callback) { Logger.info('[SocketAuthority] Shutting down') // This will close all open socket connections, and also close the underlying http server - if (this.io) - this.io.close(callback) - else - callback() + if (this.io) this.io.close(callback) + else callback() } initialize(Server) { @@ -93,7 +102,7 @@ class SocketAuthority { this.io = new SocketIO.Server(this.Server.server, { cors: { origin: '*', - methods: ["GET", "POST"] + methods: ['GET', 'POST'] } }) @@ -144,7 +153,7 @@ class SocketAuthority { // admin user can send a message to all authenticated users // displays on the web app as a toast const client = this.clients[socket.id] || {} - if (client.user && client.user.isAdminOrUp) { + if (client.user?.isAdminOrUp) { this.emitter('admin_message', payload.message || '') } else { Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) @@ -162,8 +171,8 @@ class SocketAuthority { /** * When setting up a socket connection the user needs to be associated with a socket id * for this the client will send a 'auth' event that includes the users API token - * - * @param {SocketIO.Socket} socket + * + * @param {SocketIO.Socket} socket * @param {string} token JWT */ async authenticateSocket(socket, token) { @@ -176,6 +185,7 @@ class SocketAuthority { Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') } + // get the user via the id from the decoded jwt. const user = await Database.userModel.getUserByIdOrOldId(token_data.userId) if (!user) { @@ -196,18 +206,13 @@ class SocketAuthority { client.user = user - if (!client.user.toJSONForBrowser) { - Logger.error('Invalid user...', client.user) - return - } - Logger.debug(`[SocketAuthority] User Online ${client.user.username}`) this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) // Update user lastSeen without firing sequelize bulk update hooks user.lastSeen = Date.now() - await Database.userModel.updateFromOld(user, false) + await user.save({ hooks: false }) const initialPayload = { userId: client.user.id, @@ -224,4 +229,4 @@ class SocketAuthority { this.Server.cancelLibraryScan(id) } } -module.exports = new SocketAuthority() \ No newline at end of file +module.exports = new SocketAuthority() diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 0f30c410..c468cb75 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -223,7 +223,7 @@ class LibraryController { // Only emit to users with access to library const userFilter = (user) => { - return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id) + return user.checkCanAccessLibrary?.(library.id) } SocketAuthority.emitter('library_updated', library.toJSON(), userFilter) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 8bf0c31e..5d560a58 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -17,13 +17,13 @@ const adminStats = require('../utils/queries/adminStats') // This is a controller for routes that don't have a home yet :( // class MiscController { - constructor() { } + constructor() {} /** * POST: /api/upload * Update library item - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ async handleUpload(req, res) { if (!req.user.canUpload) { @@ -42,7 +42,7 @@ class MiscController { if (!library) { return res.status(404).send(`Library not found with id ${libraryId}`) } - const folder = library.folders.find(fold => fold.id === folderId) + const folder = library.folders.find((fold) => fold.id === folderId) if (!folder) { return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`) } @@ -56,7 +56,7 @@ class MiscController { // `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`) // before sanitizing all the directory parts to remove illegal chars and finally prepending // the base folder path - const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part)) + const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part)) const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts]) await fs.ensureDir(outputDirectory) @@ -66,7 +66,8 @@ class MiscController { for (const file of files) { const path = Path.join(outputDirectory, sanitizeFilename(file.name)) - await file.mv(path) + await file + .mv(path) .then(() => { return true }) @@ -82,14 +83,14 @@ class MiscController { /** * GET: /api/tasks * Get tasks for task manager - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ getTasks(req, res) { const includeArray = (req.query.include || '').split(',') const data = { - tasks: TaskManager.tasks.map(t => t.toJSON()) + tasks: TaskManager.tasks.map((t) => t.toJSON()) } if (includeArray.includes('queue')) { @@ -104,9 +105,9 @@ class MiscController { /** * PATCH: /api/settings * Update server settings - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async updateServerSettings(req, res) { if (!req.user.isAdminOrUp) { @@ -135,9 +136,9 @@ class MiscController { /** * PATCH: /api/sorting-prefixes - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async updateSortingPrefixes(req, res) { if (!req.user.isAdminOrUp) { @@ -148,7 +149,7 @@ class MiscController { if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) { return res.status(400).send('Invalid request body') } - sortingPrefixes = [...new Set(sortingPrefixes.map(p => p?.trim?.().toLowerCase()).filter(p => p))] + sortingPrefixes = [...new Set(sortingPrefixes.map((p) => p?.trim?.().toLowerCase()).filter((p) => p))] if (!sortingPrefixes.length) { return res.status(400).send('Invalid sortingPrefixes in request body') } @@ -233,24 +234,26 @@ class MiscController { /** * POST: /api/authorize * Used to authorize an API token - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async authorize(req, res) { if (!req.user) { Logger.error('Invalid user in authorize') return res.sendStatus(401) } - const userResponse = await this.auth.getUserLoginResponsePayload(req.user) + const userResponse = await this.auth.getUserLoginResponsePayload(req.userNew) res.json(userResponse) } /** * GET: /api/tags * Get all tags - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ async getAllTags(req, res) { if (!req.user.isAdminOrUp) { @@ -292,8 +295,8 @@ class MiscController { * POST: /api/tags/rename * Rename tag * Req.body { tag, newTag } - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ async renameTag(req, res) { if (!req.user.isAdminOrUp) { @@ -321,7 +324,7 @@ class MiscController { } if (libraryItem.media.tags.includes(tag)) { - libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag + libraryItem.media.tags = libraryItem.media.tags.filter((t) => t !== tag) // Remove old tag if (!libraryItem.media.tags.includes(newTag)) { libraryItem.media.tags.push(newTag) } @@ -346,8 +349,8 @@ class MiscController { * DELETE: /api/tags/:tag * Remove a tag * :tag param is base64 encoded - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ async deleteTag(req, res) { if (!req.user.isAdminOrUp) { @@ -367,7 +370,7 @@ class MiscController { // Remove tag from items for (const libraryItem of libraryItemsWithTag) { Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`) - libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) + libraryItem.media.tags = libraryItem.media.tags.filter((t) => t !== tag) await libraryItem.media.update({ tags: libraryItem.media.tags }) @@ -385,8 +388,8 @@ class MiscController { /** * GET: /api/genres * Get all genres - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ async getAllGenres(req, res) { if (!req.user.isAdminOrUp) { @@ -427,8 +430,8 @@ class MiscController { * POST: /api/genres/rename * Rename genres * Req.body { genre, newGenre } - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ async renameGenre(req, res) { if (!req.user.isAdminOrUp) { @@ -456,7 +459,7 @@ class MiscController { } if (libraryItem.media.genres.includes(genre)) { - libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre + libraryItem.media.genres = libraryItem.media.genres.filter((t) => t !== genre) // Remove old genre if (!libraryItem.media.genres.includes(newGenre)) { libraryItem.media.genres.push(newGenre) } @@ -481,8 +484,8 @@ class MiscController { * DELETE: /api/genres/:genre * Remove a genre * :genre param is base64 encoded - * @param {*} req - * @param {*} res + * @param {*} req + * @param {*} res */ async deleteGenre(req, res) { if (!req.user.isAdminOrUp) { @@ -502,7 +505,7 @@ class MiscController { // Remove genre from items for (const libraryItem of libraryItemsWithGenre) { Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`) - libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre) + libraryItem.media.genres = libraryItem.media.genres.filter((g) => g !== genre) await libraryItem.media.update({ genres: libraryItem.media.genres }) @@ -520,13 +523,13 @@ class MiscController { /** * POST: /api/watcher/update * Update a watch path - * Req.body { libraryId, path, type, [oldPath] } + * Req.body { libraryId, path, type, [oldPath] } * type = add, unlink, rename * oldPath = required only for rename * @this import('../routers/ApiRouter') - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ updateWatchedPath(req, res) { if (!req.user.isAdminOrUp) { @@ -582,9 +585,9 @@ class MiscController { /** * GET: api/auth-settings (admin only) - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ getAuthSettings(req, res) { if (!req.user.isAdminOrUp) { @@ -597,9 +600,9 @@ class MiscController { /** * PATCH: api/auth-settings * @this import('../routers/ApiRouter') - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async updateAuthSettings(req, res) { if (!req.user.isAdminOrUp) { @@ -642,15 +645,13 @@ class MiscController { } const uris = settingsUpdate[key] - if (!Array.isArray(uris) || - (uris.includes('*') && uris.length > 1) || - uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { + if (!Array.isArray(uris) || (uris.includes('*') && uris.length > 1) || uris.some((uri) => uri !== '*' && !isValidRedirectURI(uri))) { Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`) continue } // Update the URIs - if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) { + if (Database.serverSettings[key].some((uri) => !uris.includes(uri)) || uris.some((uri) => !Database.serverSettings[key].includes(uri))) { Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`) Database.serverSettings[key] = uris hasUpdates = true @@ -704,9 +705,9 @@ class MiscController { /** * GET: /api/stats/year/:year - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getAdminStatsForYear(req, res) { if (!req.user.isAdminOrUp) { @@ -725,9 +726,9 @@ class MiscController { /** * GET: /api/logger-data * admin or up - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getLoggerData(req, res) { if (!req.user.isAdminOrUp) { diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index e222da80..fdb6d194 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -31,8 +31,8 @@ class UserController { const includes = (req.query.include || '').split(',').map((i) => i.trim()) // Minimal toJSONForBrowser does not include mediaProgress and bookmarks - const allUsers = await Database.userModel.getOldUsers() - const users = allUsers.map((u) => u.toJSONForBrowser(hideRootToken, true)) + const allUsers = await Database.userModel.findAll() + const users = allUsers.map((u) => u.toOldJSONForBrowser(hideRootToken, true)) if (includes.includes('latestSession')) { for (const user of users) { @@ -106,7 +106,7 @@ class UserController { const account = req.body const username = account.username - const usernameExists = await Database.userModel.getUserByUsername(username) + const usernameExists = await Database.userModel.checkUserExistsWithUsername(username) if (usernameExists) { return res.status(500).send('Username already taken') } @@ -149,7 +149,7 @@ class UserController { // When changing username create a new API token if (account.username !== undefined && account.username !== user.username) { - const usernameExists = await Database.userModel.getUserByUsername(account.username) + const usernameExists = await Database.userModel.checkUserExistsWithUsername(account.username) if (usernameExists) { return res.status(500).send('Username already taken') } @@ -272,7 +272,8 @@ class UserController { } if (req.params.id) { - req.reqUser = await Database.userModel.getUserById(req.params.id) + // TODO: Update to use new user model + req.reqUser = await Database.userModel.getOldUserById(req.params.id) if (!req.reqUser) { return res.sendStatus(404) } diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 5c571c73..0ab50119 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -34,29 +34,6 @@ class MediaProgress extends Model { this.createdAt } - getOldMediaProgress() { - const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' - - return { - id: this.id, - userId: this.userId, - libraryItemId: this.extraData?.libraryItemId || null, - episodeId: isPodcastEpisode ? this.mediaItemId : null, - mediaItemId: this.mediaItemId, - mediaItemType: this.mediaItemType, - duration: this.duration, - progress: this.extraData?.progress || 0, - currentTime: this.currentTime, - isFinished: !!this.isFinished, - hideFromContinueListening: !!this.hideFromContinueListening, - ebookLocation: this.ebookLocation, - ebookProgress: this.ebookProgress, - lastUpdate: this.updatedAt.valueOf(), - startedAt: this.createdAt.valueOf(), - finishedAt: this.finishedAt?.valueOf() || null - } - } - static upsertFromOld(oldMediaProgress) { const mediaProgress = this.getFromOld(oldMediaProgress) return this.upsert(mediaProgress) @@ -182,6 +159,29 @@ class MediaProgress extends Model { }) MediaProgress.belongsTo(user) } + + getOldMediaProgress() { + const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' + + return { + id: this.id, + userId: this.userId, + libraryItemId: this.extraData?.libraryItemId || null, + episodeId: isPodcastEpisode ? this.mediaItemId : null, + mediaItemId: this.mediaItemId, + mediaItemType: this.mediaItemType, + duration: this.duration, + progress: this.extraData?.progress || 0, + currentTime: this.currentTime, + isFinished: !!this.isFinished, + hideFromContinueListening: !!this.hideFromContinueListening, + ebookLocation: this.ebookLocation, + ebookProgress: this.ebookProgress, + lastUpdate: this.updatedAt.valueOf(), + startedAt: this.createdAt.valueOf(), + finishedAt: this.finishedAt?.valueOf() || null + } + } } module.exports = MediaProgress diff --git a/server/models/User.js b/server/models/User.js index 7b626d5a..4755967a 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -42,31 +42,41 @@ class User extends Model { } /** - * Get all oldUsers - * @returns {Promise} + * + * @param {string} type + * @returns */ - static async getOldUsers() { - const users = await this.findAll({ - include: this.sequelize.models.mediaProgress - }) - return users.map((u) => this.getOldUser(u)) + static getDefaultPermissionsForUserType(type) { + return { + download: true, + update: type === 'root' || type === 'admin', + delete: type === 'root', + upload: type === 'root' || type === 'admin', + accessAllLibraries: true, + accessAllTags: true, + accessExplicitContent: true, + librariesAccessible: [], + itemTagsSelected: [] + } } /** * Get old user model from new * - * @param {Object} userExpanded + * @param {User} userExpanded * @returns {oldUser} */ static getOldUser(userExpanded) { const mediaProgress = userExpanded.mediaProgresses.map((mp) => mp.getOldMediaProgress()) - const librariesAccessible = userExpanded.permissions?.librariesAccessible || [] - const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || [] - const permissions = userExpanded.permissions || {} + const librariesAccessible = [...(userExpanded.permissions?.librariesAccessible || [])] + const itemTagsSelected = [...(userExpanded.permissions?.itemTagsSelected || [])] + const permissions = { ...(userExpanded.permissions || {}) } delete permissions.librariesAccessible delete permissions.itemTagsSelected + const seriesHideFromContinueListening = userExpanded.extraData?.seriesHideFromContinueListening || [] + return new oldUser({ id: userExpanded.id, oldUserId: userExpanded.extraData?.oldUserId || null, @@ -76,7 +86,7 @@ class User extends Model { type: userExpanded.type, token: userExpanded.token, mediaProgress, - seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [], + seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: userExpanded.bookmarks, isActive: userExpanded.isActive, isLocked: userExpanded.isLocked, @@ -168,32 +178,35 @@ class User extends Model { * Create root user * @param {string} username * @param {string} pash - * @param {Auth} auth - * @returns {Promise} + * @param {import('../Auth')} auth + * @returns {Promise} */ static async createRootUser(username, pash, auth) { const userId = uuidv4() const token = await auth.generateAccessToken({ id: userId, username }) - const newRoot = new oldUser({ + const newUser = { id: userId, type: 'root', username, pash, token, isActive: true, - createdAt: Date.now() - }) - await this.createFromOld(newRoot) - return newRoot + permissions: this.getDefaultPermissionsForUserType('root'), + bookmarks: [], + extraData: { + seriesHideFromContinueListening: [] + } + } + return this.create(newUser) } /** * Create user from openid userinfo * @param {Object} userinfo - * @param {Auth} auth - * @returns {Promise} + * @param {import('../Auth')} auth + * @returns {Promise} */ static async createUserFromOpenIdUserInfo(userinfo, auth) { const userId = uuidv4() @@ -203,7 +216,7 @@ class User extends Model { const token = await auth.generateAccessToken({ id: userId, username }) - const newUser = new oldUser({ + const newUser = { id: userId, type: 'user', username, @@ -211,51 +224,30 @@ class User extends Model { pash: null, token, isActive: true, - authOpenIDSub: userinfo.sub, - createdAt: Date.now() - }) - if (await this.createFromOld(newUser)) { - SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser()) - return newUser + permissions: this.getDefaultPermissionsForUserType('user'), + bookmarks: [], + extraData: { + authOpenIDSub: userinfo.sub, + seriesHideFromContinueListening: [] + } + } + const user = await this.create(newUser) + + if (user) { + SocketAuthority.adminEmitter('user_added', user.toOldJSONForBrowser()) + return user } return null } - /** - * Get a user by id or by the old database id - * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id - * @param {string} userId - * @returns {Promise} null if not found - */ - static async getUserByIdOrOldId(userId) { - if (!userId) return null - const user = await this.findOne({ - where: { - [sequelize.Op.or]: [ - { - id: userId - }, - { - extraData: { - [sequelize.Op.substring]: userId - } - } - ] - }, - include: this.sequelize.models.mediaProgress - }) - if (!user) return null - return this.getOldUser(user) - } - /** * Get user by username case insensitive * @param {string} username - * @returns {Promise} returns null if not found + * @returns {Promise} */ static async getUserByUsername(username) { if (!username) return null - const user = await this.findOne({ + return this.findOne({ where: { username: { [sequelize.Op.like]: username @@ -263,18 +255,16 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) - if (!user) return null - return this.getOldUser(user) } /** * Get user by email case insensitive - * @param {string} username - * @returns {Promise} returns null if not found + * @param {string} email + * @returns {Promise} */ static async getUserByEmail(email) { if (!email) return null - const user = await this.findOne({ + return this.findOne({ where: { email: { [sequelize.Op.like]: email @@ -282,20 +272,45 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) - if (!user) return null - return this.getOldUser(user) } /** * Get user by id * @param {string} userId - * @returns {Promise} returns null if not found + * @returns {Promise} */ static async getUserById(userId) { if (!userId) return null - const user = await this.findByPk(userId, { + return this.findByPk(userId, { include: this.sequelize.models.mediaProgress }) + } + + /** + * Get user by id or old id + * JWT tokens generated before 2.3.0 used old user ids + * + * @param {string} userId + * @returns {Promise} + */ + static async getUserByIdOrOldId(userId) { + if (!userId) return null + return this.findOne({ + where: { + [sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }] + }, + include: this.sequelize.models.mediaProgress + }) + } + + /** + * @deprecated + * Get old user by id + * @param {string} userId + * @returns {Promise} returns null if not found + */ + static async getOldUserById(userId) { + const user = await this.getUserById(userId) if (!user) return null return this.getOldUser(user) } @@ -303,16 +318,14 @@ class User extends Model { /** * Get user by openid sub * @param {string} sub - * @returns {Promise} returns null if not found + * @returns {Promise} */ static async getUserByOpenIDSub(sub) { if (!sub) return null - const user = await this.findOne({ + return this.findOne({ where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub), include: this.sequelize.models.mediaProgress }) - if (!user) return null - return this.getOldUser(user) } /** @@ -344,6 +357,20 @@ class User extends Model { return count > 0 } + /** + * Check if user exists with username + * @param {string} username + * @returns {boolean} + */ + static async checkUserExistsWithUsername(username) { + const count = await this.count({ + where: { + username + } + }) + return count > 0 + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -380,6 +407,99 @@ class User extends Model { } ) } + + get isAdminOrUp() { + return this.type === 'root' || this.type === 'admin' + } + get isUser() { + return this.type === 'user' + } + /** @type {string|null} */ + get authOpenIDSub() { + return this.extraData?.authOpenIDSub || null + } + + /** + * User data for clients + * Emitted on socket events user_online, user_offline and user_stream_update + * + * @param {import('../objects/PlaybackSession')[]} sessions + * @returns + */ + toJSONForPublic(sessions) { + const session = sessions?.find((s) => s.userId === this.id)?.toJSONForClient() || null + return { + id: this.id, + username: this.username, + type: this.type, + session, + lastSeen: this.lastSeen?.valueOf() || null, + createdAt: this.createdAt.valueOf() + } + } + + /** + * User data for browser using old model + * + * @param {boolean} [hideRootToken=false] + * @param {boolean} [minimal=false] + * @returns + */ + toOldJSONForBrowser(hideRootToken = false, minimal = false) { + const seriesHideFromContinueListening = this.extraData?.seriesHideFromContinueListening || [] + const librariesAccessible = this.permissions?.librariesAccessible || [] + const itemTagsSelected = this.permissions?.itemTagsSelected || [] + const permissions = { ...this.permissions } + delete permissions.librariesAccessible + delete permissions.itemTagsSelected + + const json = { + id: this.id, + username: this.username, + email: this.email, + type: this.type, + token: this.type === 'root' && hideRootToken ? '' : this.token, + mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], + seriesHideFromContinueListening: [...seriesHideFromContinueListening], + bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], + isActive: this.isActive, + isLocked: this.isLocked, + lastSeen: this.lastSeen?.valueOf() || null, + createdAt: this.createdAt.valueOf(), + permissions: permissions, + librariesAccessible: [...librariesAccessible], + itemTagsSelected: [...itemTagsSelected], + hasOpenIDLink: !!this.authOpenIDSub + } + if (minimal) { + delete json.mediaProgress + delete json.bookmarks + } + return json + } + + /** + * Check user has access to library + * + * @param {string} libraryId + * @returns {boolean} + */ + checkCanAccessLibrary(libraryId) { + if (this.permissions?.accessAllLibraries) return true + if (!this.permissions?.librariesAccessible) return false + return this.permissions.librariesAccessible.includes(libraryId) + } + + /** + * Get first available library id for user + * + * @param {string[]} libraryIds + * @returns {string|null} + */ + getDefaultLibraryId(libraryIds) { + // Libraries should already be in ascending display order, find first accessible + return libraryIds.find((lid) => this.checkCanAccessLibrary(lid)) || null + } } module.exports = User diff --git a/server/objects/settings/EmailSettings.js b/server/objects/settings/EmailSettings.js index 330e1b9c..db3ad754 100644 --- a/server/objects/settings/EmailSettings.js +++ b/server/objects/settings/EmailSettings.js @@ -140,7 +140,7 @@ class EmailSettings { /** * * @param {EreaderDeviceObject} device - * @param {import('../user/User')} user + * @param {import('../../models/User')} user * @returns {boolean} */ checkUserCanAccessDevice(device, user) { @@ -158,7 +158,7 @@ class EmailSettings { /** * Get ereader devices accessible to user * - * @param {import('../user/User')} user + * @param {import('../../models/User')} user * @returns {EreaderDeviceObject[]} */ getEReaderDevices(user) { diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js index 3d38cca6..85631783 100644 --- a/server/utils/migrations/dbMigration.js +++ b/server/utils/migrations/dbMigration.js @@ -1,6 +1,6 @@ const { DataTypes, QueryInterface } = require('sequelize') const Path = require('path') -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 const Logger = require('../../Logger') const fs = require('../../libs/fsExtra') const oldDbFiles = require('./oldDbFiles') @@ -36,25 +36,14 @@ function getDeviceInfoString(deviceInfo, UserId) { if (!deviceInfo) return null if (deviceInfo.deviceId) return deviceInfo.deviceId - const keys = [ - UserId, - deviceInfo.browserName || null, - deviceInfo.browserVersion || null, - deviceInfo.osName || null, - deviceInfo.osVersion || null, - deviceInfo.clientVersion || null, - deviceInfo.manufacturer || null, - deviceInfo.model || null, - deviceInfo.sdkVersion || null, - deviceInfo.ipAddress || null - ].map(k => k || '') + const keys = [UserId, deviceInfo.browserName || null, deviceInfo.browserVersion || null, deviceInfo.osName || null, deviceInfo.osVersion || null, deviceInfo.clientVersion || null, deviceInfo.manufacturer || null, deviceInfo.model || null, deviceInfo.sdkVersion || null, deviceInfo.ipAddress || null].map((k) => k || '') return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64') } /** * Migrate oldLibraryItem.media to Book model * Migrate BookSeries and BookAuthor - * @param {objects.LibraryItem} oldLibraryItem + * @param {objects.LibraryItem} oldLibraryItem * @param {object} LibraryItem models.LibraryItem object * @returns {object} { book: object, bookSeries: [], bookAuthor: [] } */ @@ -67,7 +56,7 @@ function migrateBook(oldLibraryItem, LibraryItem) { bookAuthor: [] } - const tracks = (oldBook.audioFiles || []).filter(af => !af.exclude && !af.invalid) + const tracks = (oldBook.audioFiles || []).filter((af) => !af.exclude && !af.invalid) let duration = 0 for (const track of tracks) { if (track.duration !== null && !isNaN(track.duration)) { @@ -156,7 +145,7 @@ function migrateBook(oldLibraryItem, LibraryItem) { /** * Migrate oldLibraryItem.media to Podcast model * Migrate PodcastEpisode - * @param {objects.LibraryItem} oldLibraryItem + * @param {objects.LibraryItem} oldLibraryItem * @param {object} LibraryItem models.LibraryItem object * @returns {object} { podcast: object, podcastEpisode: [] } */ @@ -239,7 +228,7 @@ function migratePodcast(oldLibraryItem, LibraryItem) { /** * Migrate libraryItems to LibraryItem, Book, Podcast models - * @param {Array} oldLibraryItems + * @param {Array} oldLibraryItems * @returns {object} { libraryItem: [], book: [], podcast: [], podcastEpisode: [], bookSeries: [], bookAuthor: [] } */ function migrateLibraryItems(oldLibraryItems) { @@ -298,7 +287,7 @@ function migrateLibraryItems(oldLibraryItems) { updatedAt: oldLibraryItem.updatedAt, libraryId, libraryFolderId, - libraryFiles: oldLibraryItem.libraryFiles.map(lf => { + libraryFiles: oldLibraryItem.libraryFiles.map((lf) => { if (lf.isSupplementary === undefined) lf.isSupplementary = null return lf }) @@ -306,7 +295,7 @@ function migrateLibraryItems(oldLibraryItems) { oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id _newRecords.libraryItem.push(LibraryItem) - // + // // Migrate Book/Podcast // if (oldLibraryItem.mediaType === 'book') { @@ -329,7 +318,7 @@ function migrateLibraryItems(oldLibraryItems) { /** * Migrate Library and LibraryFolder - * @param {Array} oldLibraries + * @param {Array} oldLibraries * @returns {object} { library: [], libraryFolder: [] } */ function migrateLibraries(oldLibraries) { @@ -343,7 +332,7 @@ function migrateLibraries(oldLibraries) { continue } - // + // // Migrate Library // const Library = { @@ -361,7 +350,7 @@ function migrateLibraries(oldLibraries) { oldDbIdMap.libraries[oldLibrary.id] = Library.id _newRecords.library.push(Library) - // + // // Migrate LibraryFolders // for (const oldFolder of oldLibrary.folders) { @@ -382,21 +371,27 @@ function migrateLibraries(oldLibraries) { /** * Migrate Author * Previously Authors were shared between libraries, this will ensure every author has one library - * @param {Array} oldAuthors - * @param {Array} oldLibraryItems + * @param {Array} oldAuthors + * @param {Array} oldLibraryItems * @returns {Array} Array of Author model objs */ function migrateAuthors(oldAuthors, oldLibraryItems) { const _newRecords = [] for (const oldAuthor of oldAuthors) { // Get an array of NEW library ids that have this author - const librariesWithThisAuthor = [...new Set(oldLibraryItems.map(li => { - if (!li.media.metadata.authors?.some(au => au.id === oldAuthor.id)) return null - if (!oldDbIdMap.libraries[li.libraryId]) { - Logger.warn(`[dbMigration] Authors library id ${li.libraryId} was not migrated`) - } - return oldDbIdMap.libraries[li.libraryId] - }).filter(lid => lid))] + const librariesWithThisAuthor = [ + ...new Set( + oldLibraryItems + .map((li) => { + if (!li.media.metadata.authors?.some((au) => au.id === oldAuthor.id)) return null + if (!oldDbIdMap.libraries[li.libraryId]) { + Logger.warn(`[dbMigration] Authors library id ${li.libraryId} was not migrated`) + } + return oldDbIdMap.libraries[li.libraryId] + }) + .filter((lid) => lid) + ) + ] if (!librariesWithThisAuthor.length) { Logger.error(`[dbMigration] Author ${oldAuthor.name} was not found in any libraries`) @@ -426,8 +421,8 @@ function migrateAuthors(oldAuthors, oldLibraryItems) { /** * Migrate Series * Previously Series were shared between libraries, this will ensure every series has one library - * @param {Array} oldSerieses - * @param {Array} oldLibraryItems + * @param {Array} oldSerieses + * @param {Array} oldLibraryItems * @returns {Array} Array of Series model objs */ function migrateSeries(oldSerieses, oldLibraryItems) { @@ -436,10 +431,16 @@ function migrateSeries(oldSerieses, oldLibraryItems) { // Series will be separate between libraries for (const oldSeries of oldSerieses) { // Get an array of NEW library ids that have this series - const librariesWithThisSeries = [...new Set(oldLibraryItems.map(li => { - if (!li.media.metadata.series?.some(se => se.id === oldSeries.id)) return null - return oldDbIdMap.libraries[li.libraryId] - }).filter(lid => lid))] + const librariesWithThisSeries = [ + ...new Set( + oldLibraryItems + .map((li) => { + if (!li.media.metadata.series?.some((se) => se.id === oldSeries.id)) return null + return oldDbIdMap.libraries[li.libraryId] + }) + .filter((lid) => lid) + ) + ] if (!librariesWithThisSeries.length) { Logger.error(`[dbMigration] Series ${oldSeries.name} was not found in any libraries`) @@ -465,7 +466,7 @@ function migrateSeries(oldSerieses, oldLibraryItems) { /** * Migrate users to User and MediaProgress models - * @param {Array} oldUsers + * @param {Array} oldUsers * @returns {object} { user: [], mediaProgress: [] } */ function migrateUsers(oldUsers) { @@ -474,29 +475,33 @@ function migrateUsers(oldUsers) { mediaProgress: [] } for (const oldUser of oldUsers) { - // + // // Migrate User // // Convert old library ids to new ids - const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter(li => li) + const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter((li) => li) // Convert old library item ids to new ids - const bookmarks = (oldUser.bookmarks || []).map(bm => { - bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId] - return bm - }).filter(bm => bm.libraryItemId) + const bookmarks = (oldUser.bookmarks || []) + .map((bm) => { + bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId] + return bm + }) + .filter((bm) => bm.libraryItemId) // Convert old series ids to new - const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || []).map(oldSeriesId => { - // Series were split to be per library - // This will use the first series it finds - for (const libraryId in oldDbIdMap.series) { - if (oldDbIdMap.series[libraryId][oldSeriesId]) { - return oldDbIdMap.series[libraryId][oldSeriesId] + const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || []) + .map((oldSeriesId) => { + // Series were split to be per library + // This will use the first series it finds + for (const libraryId in oldDbIdMap.series) { + if (oldDbIdMap.series[libraryId][oldSeriesId]) { + return oldDbIdMap.series[libraryId][oldSeriesId] + } } - } - return null - }).filter(se => se) + return null + }) + .filter((se) => se) const User = { id: uuidv4(), @@ -521,7 +526,7 @@ function migrateUsers(oldUsers) { oldDbIdMap.users[oldUser.id] = User.id _newRecords.user.push(User) - // + // // Migrate MediaProgress // for (const oldMediaProgress of oldUser.mediaProgress) { @@ -566,7 +571,7 @@ function migrateUsers(oldUsers) { /** * Migrate playbackSessions to PlaybackSession and Device models - * @param {Array} oldSessions + * @param {Array} oldSessions * @returns {object} { playbackSession: [], device: [] } */ function migrateSessions(oldSessions) { @@ -690,7 +695,7 @@ function migrateSessions(oldSessions) { /** * Migrate collections to Collection & CollectionBook - * @param {Array} oldCollections + * @param {Array} oldCollections * @returns {object} { collection: [], collectionBook: [] } */ function migrateCollections(oldCollections) { @@ -705,7 +710,7 @@ function migrateCollections(oldCollections) { continue } - const BookIds = oldCollection.books.map(lid => oldDbIdMap.books[lid]).filter(bid => bid) + const BookIds = oldCollection.books.map((lid) => oldDbIdMap.books[lid]).filter((bid) => bid) if (!BookIds.length) { Logger.warn(`[dbMigration] migrateCollections: Collection "${oldCollection.name}" has no books`) continue @@ -739,7 +744,7 @@ function migrateCollections(oldCollections) { /** * Migrate playlists to Playlist and PlaylistMediaItem - * @param {Array} oldPlaylists + * @param {Array} oldPlaylists * @returns {object} { playlist: [], playlistMediaItem: [] } */ function migratePlaylists(oldPlaylists) { @@ -806,7 +811,7 @@ function migratePlaylists(oldPlaylists) { /** * Migrate feeds to Feed and FeedEpisode models - * @param {Array} oldFeeds + * @param {Array} oldFeeds * @returns {object} { feed: [], feedEpisode: [] } */ function migrateFeeds(oldFeeds) { @@ -907,14 +912,14 @@ function migrateFeeds(oldFeeds) { /** * Migrate ServerSettings, NotificationSettings and EmailSettings to Setting model - * @param {Array} oldSettings + * @param {Array} oldSettings * @returns {Array} Array of Setting model objs */ function migrateSettings(oldSettings) { const _newRecords = [] - const serverSettings = oldSettings.find(s => s.id === 'server-settings') - const notificationSettings = oldSettings.find(s => s.id === 'notification-settings') - const emailSettings = oldSettings.find(s => s.id === 'email-settings') + const serverSettings = oldSettings.find((s) => s.id === 'server-settings') + const notificationSettings = oldSettings.find((s) => s.id === 'notification-settings') + const emailSettings = oldSettings.find((s) => s.id === 'email-settings') if (serverSettings) { _newRecords.push({ @@ -946,7 +951,7 @@ function migrateSettings(oldSettings) { /** * Load old libraries and bulkCreate new Library and LibraryFolder rows - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigrateLibraries(DatabaseModels) { const oldLibraries = await oldDbFiles.loadOldData('libraries') @@ -959,7 +964,7 @@ async function handleMigrateLibraries(DatabaseModels) { /** * Load old EmailSettings, NotificationSettings and ServerSettings and bulkCreate new Setting rows - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigrateSettings(DatabaseModels) { const oldSettings = await oldDbFiles.loadOldData('settings') @@ -970,7 +975,7 @@ async function handleMigrateSettings(DatabaseModels) { /** * Load old authors and bulkCreate new Author rows - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels * @param {Array} oldLibraryItems */ async function handleMigrateAuthors(DatabaseModels, oldLibraryItems) { @@ -982,7 +987,7 @@ async function handleMigrateAuthors(DatabaseModels, oldLibraryItems) { /** * Load old series and bulkCreate new Series rows - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels * @param {Array} oldLibraryItems */ async function handleMigrateSeries(DatabaseModels, oldLibraryItems) { @@ -994,7 +999,7 @@ async function handleMigrateSeries(DatabaseModels, oldLibraryItems) { /** * bulkCreate new LibraryItem, Book and Podcast rows - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels * @param {Array} oldLibraryItems */ async function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) { @@ -1008,7 +1013,7 @@ async function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) { /** * Migrate authors, series then library items in chunks * Authors and series require old library items loaded first - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) { const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') @@ -1026,7 +1031,7 @@ async function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) { /** * Load old users and bulkCreate new User rows - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigrateUsers(DatabaseModels) { const oldUsers = await oldDbFiles.loadOldData('users') @@ -1039,7 +1044,7 @@ async function handleMigrateUsers(DatabaseModels) { /** * Load old sessions and bulkCreate new PlaybackSession & Device rows - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigrateSessions(DatabaseModels) { const oldSessions = await oldDbFiles.loadOldData('sessions') @@ -1055,12 +1060,11 @@ async function handleMigrateSessions(DatabaseModels) { await DatabaseModels[model].bulkCreate(newSessionRecords[model]) } } - } /** * Load old collections and bulkCreate new Collection, CollectionBook models - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigrateCollections(DatabaseModels) { const oldCollections = await oldDbFiles.loadOldData('collections') @@ -1073,7 +1077,7 @@ async function handleMigrateCollections(DatabaseModels) { /** * Load old playlists and bulkCreate new Playlist, PlaylistMediaItem models - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigratePlaylists(DatabaseModels) { const oldPlaylists = await oldDbFiles.loadOldData('playlists') @@ -1086,7 +1090,7 @@ async function handleMigratePlaylists(DatabaseModels) { /** * Load old feeds and bulkCreate new Feed, FeedEpisode models - * @param {Map} DatabaseModels + * @param {Map} DatabaseModels */ async function handleMigrateFeeds(DatabaseModels) { const oldFeeds = await oldDbFiles.loadOldData('feeds') @@ -1152,21 +1156,36 @@ module.exports.checkShouldMigrate = async () => { /** * Migration from 2.3.0 to 2.3.1 - create extraData columns in LibraryItem and PodcastEpisode - * @param {QueryInterface} queryInterface + * @param {QueryInterface} queryInterface */ async function migrationPatchNewColumns(queryInterface) { try { - return queryInterface.sequelize.transaction(t => { + return queryInterface.sequelize.transaction((t) => { return Promise.all([ - queryInterface.addColumn('libraryItems', 'extraData', { - type: DataTypes.JSON - }, { transaction: t }), - queryInterface.addColumn('podcastEpisodes', 'extraData', { - type: DataTypes.JSON - }, { transaction: t }), - queryInterface.addColumn('libraries', 'extraData', { - type: DataTypes.JSON - }, { transaction: t }) + queryInterface.addColumn( + 'libraryItems', + 'extraData', + { + type: DataTypes.JSON + }, + { transaction: t } + ), + queryInterface.addColumn( + 'podcastEpisodes', + 'extraData', + { + type: DataTypes.JSON + }, + { transaction: t } + ), + queryInterface.addColumn( + 'libraries', + 'extraData', + { + type: DataTypes.JSON + }, + { transaction: t } + ) ]) }) } catch (error) { @@ -1177,7 +1196,7 @@ async function migrationPatchNewColumns(queryInterface) { /** * Migration from 2.3.0 to 2.3.1 - old library item ids - * @param {/src/Database} ctx + * @param {/src/Database} ctx */ async function handleOldLibraryItems(ctx) { const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') @@ -1188,7 +1207,7 @@ async function handleOldLibraryItems(ctx) { for (const libraryItem of libraryItems) { // Find matching old library item by ino - const matchingOldLibraryItem = oldLibraryItems.find(oli => oli.ino === libraryItem.ino) + const matchingOldLibraryItem = oldLibraryItems.find((oli) => oli.ino === libraryItem.ino) if (matchingOldLibraryItem) { oldDbIdMap.libraryItems[matchingOldLibraryItem.id] = libraryItem.id @@ -1202,7 +1221,7 @@ async function handleOldLibraryItems(ctx) { if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) { for (const podcastEpisode of libraryItem.media.episodes) { // Find matching old episode by audio file ino - const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find(oep => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino) + const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino) if (matchingOldPodcastEpisode) { oldDbIdMap.podcastEpisodes[matchingOldPodcastEpisode.id] = podcastEpisode.id @@ -1235,7 +1254,7 @@ async function handleOldLibraryItems(ctx) { /** * Migration from 2.3.0 to 2.3.1 - updating oldLibraryId - * @param {/src/Database} ctx + * @param {/src/Database} ctx */ async function handleOldLibraries(ctx) { const oldLibraries = await oldDbFiles.loadOldData('libraries') @@ -1244,11 +1263,11 @@ async function handleOldLibraries(ctx) { let librariesUpdated = 0 for (const library of libraries) { // Find matching old library using exact match on folder paths, exact match on library name - const matchingOldLibrary = oldLibraries.find(ol => { + const matchingOldLibrary = oldLibraries.find((ol) => { if (ol.name !== library.name) { return false } - const folderPaths = ol.folders?.map(f => f.fullPath) || [] + const folderPaths = ol.folders?.map((f) => f.fullPath) || [] return folderPaths.join(',') === library.folderPaths.join(',') }) @@ -1264,42 +1283,51 @@ async function handleOldLibraries(ctx) { /** * Migration from 2.3.0 to 2.3.1 - fixing librariesAccessible and bookmarks - * @param {/src/Database} ctx + * @param {import('../../Database')} ctx */ async function handleOldUsers(ctx) { - const users = await ctx.models.user.getOldUsers() + const usersNew = await ctx.userModel.findAll({ + include: ctx.models.mediaProgress + }) + const users = usersNew.map((u) => ctx.userModel.getOldUser(u)) let usersUpdated = 0 for (const user of users) { let hasUpdates = false if (user.bookmarks?.length) { - user.bookmarks = user.bookmarks.map(bm => { - // Only update if this is not the old id format - if (!bm.libraryItemId.startsWith('li_')) return bm + user.bookmarks = user.bookmarks + .map((bm) => { + // Only update if this is not the old id format + if (!bm.libraryItemId.startsWith('li_')) return bm - bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId] - hasUpdates = true - return bm - }).filter(bm => bm.libraryItemId) + bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId] + hasUpdates = true + return bm + }) + .filter((bm) => bm.libraryItemId) } // Convert old library ids to new library ids if (user.librariesAccessible?.length) { - user.librariesAccessible = user.librariesAccessible.map(lid => { - if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change - hasUpdates = true - return oldDbIdMap.libraries[lid] - }).filter(lid => lid) + user.librariesAccessible = user.librariesAccessible + .map((lid) => { + if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change + hasUpdates = true + return oldDbIdMap.libraries[lid] + }) + .filter((lid) => lid) } if (user.seriesHideFromContinueListening?.length) { - user.seriesHideFromContinueListening = user.seriesHideFromContinueListening.map((seriesId) => { - if (seriesId.startsWith('se_')) { - hasUpdates = true - return null // Filter out old series ids - } - return seriesId - }).filter(se => se) + user.seriesHideFromContinueListening = user.seriesHideFromContinueListening + .map((seriesId) => { + if (seriesId.startsWith('se_')) { + hasUpdates = true + return null // Filter out old series ids + } + return seriesId + }) + .filter((se) => se) } if (hasUpdates) { @@ -1312,7 +1340,7 @@ async function handleOldUsers(ctx) { /** * Migration from 2.3.0 to 2.3.1 - * @param {/src/Database} ctx + * @param {/src/Database} ctx */ module.exports.migrationPatch = async (ctx) => { const queryInterface = ctx.sequelize.getQueryInterface() @@ -1328,7 +1356,7 @@ module.exports.migrationPatch = async (ctx) => { } const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip') - if (!await fs.pathExists(oldDbPath)) { + if (!(await fs.pathExists(oldDbPath))) { Logger.info(`[dbMigration] Migration patch 2.3.0+ unnecessary - no oldDb.zip found`) return } @@ -1337,7 +1365,7 @@ module.exports.migrationPatch = async (ctx) => { Logger.info(`[dbMigration] Applying migration patch from 2.3.0+`) // Extract from oldDb.zip - if (!await oldDbFiles.checkExtractItemsUsersAndLibraries()) { + if (!(await oldDbFiles.checkExtractItemsUsersAndLibraries())) { return } @@ -1354,8 +1382,8 @@ module.exports.migrationPatch = async (ctx) => { /** * Migration from 2.3.3 to 2.3.4 * Populating the size column on libraryItem - * @param {/src/Database} ctx - * @param {number} offset + * @param {/src/Database} ctx + * @param {number} offset */ async function migrationPatch2LibraryItems(ctx, offset = 0) { const libraryItems = await ctx.models.libraryItem.findAll({ @@ -1368,7 +1396,7 @@ async function migrationPatch2LibraryItems(ctx, offset = 0) { for (const libraryItem of libraryItems) { if (libraryItem.libraryFiles?.length) { let size = 0 - libraryItem.libraryFiles.forEach(lf => { + libraryItem.libraryFiles.forEach((lf) => { if (!isNaN(lf.metadata?.size)) { size += Number(lf.metadata.size) } @@ -1396,8 +1424,8 @@ async function migrationPatch2LibraryItems(ctx, offset = 0) { /** * Migration from 2.3.3 to 2.3.4 * Populating the duration & titleIgnorePrefix column on book - * @param {/src/Database} ctx - * @param {number} offset + * @param {/src/Database} ctx + * @param {number} offset */ async function migrationPatch2Books(ctx, offset = 0) { const books = await ctx.models.book.findAll({ @@ -1411,7 +1439,7 @@ async function migrationPatch2Books(ctx, offset = 0) { let duration = 0 if (book.audioFiles?.length) { - const tracks = book.audioFiles.filter(af => !af.exclude && !af.invalid) + const tracks = book.audioFiles.filter((af) => !af.exclude && !af.invalid) for (const track of tracks) { if (track.duration !== null && !isNaN(track.duration)) { duration += track.duration @@ -1442,8 +1470,8 @@ async function migrationPatch2Books(ctx, offset = 0) { /** * Migration from 2.3.3 to 2.3.4 * Populating the titleIgnorePrefix column on podcast - * @param {/src/Database} ctx - * @param {number} offset + * @param {/src/Database} ctx + * @param {number} offset */ async function migrationPatch2Podcasts(ctx, offset = 0) { const podcasts = await ctx.models.podcast.findAll({ @@ -1476,8 +1504,8 @@ async function migrationPatch2Podcasts(ctx, offset = 0) { /** * Migration from 2.3.3 to 2.3.4 * Populating the nameIgnorePrefix column on series - * @param {/src/Database} ctx - * @param {number} offset + * @param {/src/Database} ctx + * @param {number} offset */ async function migrationPatch2Series(ctx, offset = 0) { const allSeries = await ctx.models.series.findAll({ @@ -1510,8 +1538,8 @@ async function migrationPatch2Series(ctx, offset = 0) { /** * Migration from 2.3.3 to 2.3.4 * Populating the lastFirst column on author - * @param {/src/Database} ctx - * @param {number} offset + * @param {/src/Database} ctx + * @param {number} offset */ async function migrationPatch2Authors(ctx, offset = 0) { const authors = await ctx.models.author.findAll({ @@ -1546,8 +1574,8 @@ async function migrationPatch2Authors(ctx, offset = 0) { /** * Migration from 2.3.3 to 2.3.4 * Populating the createdAt column on bookAuthor - * @param {/src/Database} ctx - * @param {number} offset + * @param {/src/Database} ctx + * @param {number} offset */ async function migrationPatch2BookAuthors(ctx, offset = 0) { const bookAuthors = await ctx.models.bookAuthor.findAll({ @@ -1581,8 +1609,8 @@ async function migrationPatch2BookAuthors(ctx, offset = 0) { /** * Migration from 2.3.3 to 2.3.4 * Populating the createdAt column on bookSeries - * @param {/src/Database} ctx - * @param {number} offset + * @param {/src/Database} ctx + * @param {number} offset */ async function migrationPatch2BookSeries(ctx, offset = 0) { const allBookSeries = await ctx.models.bookSeries.findAll({ @@ -1616,7 +1644,7 @@ async function migrationPatch2BookSeries(ctx, offset = 0) { /** * Migration from 2.3.3 to 2.3.4 * Adding coverPath column to Feed model - * @param {/src/Database} ctx + * @param {/src/Database} ctx */ module.exports.migrationPatch2 = async (ctx) => { const queryInterface = ctx.sequelize.getQueryInterface() @@ -1631,44 +1659,95 @@ module.exports.migrationPatch2 = async (ctx) => { Logger.info(`[dbMigration] Applying migration patch from 2.3.3+`) try { - await queryInterface.sequelize.transaction(t => { + await queryInterface.sequelize.transaction((t) => { const queries = [] if (!bookAuthorsTableDescription?.createdAt) { - queries.push(...[ - queryInterface.addColumn('bookAuthors', 'createdAt', { - type: DataTypes.DATE - }, { transaction: t }), - queryInterface.addColumn('bookSeries', 'createdAt', { - type: DataTypes.DATE - }, { transaction: t }), - ]) + queries.push( + ...[ + queryInterface.addColumn( + 'bookAuthors', + 'createdAt', + { + type: DataTypes.DATE + }, + { transaction: t } + ), + queryInterface.addColumn( + 'bookSeries', + 'createdAt', + { + type: DataTypes.DATE + }, + { transaction: t } + ) + ] + ) } if (!authorsTableDescription?.lastFirst) { - queries.push(...[ - queryInterface.addColumn('authors', 'lastFirst', { - type: DataTypes.STRING - }, { transaction: t }), - queryInterface.addColumn('libraryItems', 'size', { - type: DataTypes.BIGINT - }, { transaction: t }), - queryInterface.addColumn('books', 'duration', { - type: DataTypes.FLOAT - }, { transaction: t }), - queryInterface.addColumn('books', 'titleIgnorePrefix', { - type: DataTypes.STRING - }, { transaction: t }), - queryInterface.addColumn('podcasts', 'titleIgnorePrefix', { - type: DataTypes.STRING - }, { transaction: t }), - queryInterface.addColumn('series', 'nameIgnorePrefix', { - type: DataTypes.STRING - }, { transaction: t }), - ]) + queries.push( + ...[ + queryInterface.addColumn( + 'authors', + 'lastFirst', + { + type: DataTypes.STRING + }, + { transaction: t } + ), + queryInterface.addColumn( + 'libraryItems', + 'size', + { + type: DataTypes.BIGINT + }, + { transaction: t } + ), + queryInterface.addColumn( + 'books', + 'duration', + { + type: DataTypes.FLOAT + }, + { transaction: t } + ), + queryInterface.addColumn( + 'books', + 'titleIgnorePrefix', + { + type: DataTypes.STRING + }, + { transaction: t } + ), + queryInterface.addColumn( + 'podcasts', + 'titleIgnorePrefix', + { + type: DataTypes.STRING + }, + { transaction: t } + ), + queryInterface.addColumn( + 'series', + 'nameIgnorePrefix', + { + type: DataTypes.STRING + }, + { transaction: t } + ) + ] + ) } if (!feedTableDescription?.coverPath) { - queries.push(queryInterface.addColumn('feeds', 'coverPath', { - type: DataTypes.STRING - }, { transaction: t })) + queries.push( + queryInterface.addColumn( + 'feeds', + 'coverPath', + { + type: DataTypes.STRING + }, + { transaction: t } + ) + ) } return Promise.all(queries) }) @@ -1708,4 +1787,4 @@ module.exports.migrationPatch2 = async (ctx) => { Logger.error(`[dbMigration] Migration from 2.3.3+ column creation failed`, error) throw new Error('Migration 2.3.3+ failed ' + error) } -} \ No newline at end of file +}