mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-27 04:29:41 +00:00
Merge branch 'advplyr:master' into master
This commit is contained in:
commit
8321ba6291
138 changed files with 6154 additions and 1541 deletions
906
server/Auth.js
906
server/Auth.js
File diff suppressed because it is too large
Load diff
|
|
@ -42,6 +42,16 @@ class Database {
|
|||
return this.models.user
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Session')} */
|
||||
get sessionModel() {
|
||||
return this.models.session
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/ApiKey')} */
|
||||
get apiKeyModel() {
|
||||
return this.models.apiKey
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Library')} */
|
||||
get libraryModel() {
|
||||
return this.models.library
|
||||
|
|
@ -311,6 +321,8 @@ class Database {
|
|||
|
||||
buildModels(force = false) {
|
||||
require('./models/User').init(this.sequelize)
|
||||
require('./models/Session').init(this.sequelize)
|
||||
require('./models/ApiKey').init(this.sequelize)
|
||||
require('./models/Library').init(this.sequelize)
|
||||
require('./models/LibraryFolder').init(this.sequelize)
|
||||
require('./models/Book').init(this.sequelize)
|
||||
|
|
@ -656,6 +668,9 @@ class Database {
|
|||
* Series should have atleast one Book
|
||||
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
||||
* Remove playback sessions that are 3 seconds or less
|
||||
* Remove duplicate mediaProgresses
|
||||
* Remove expired auth sessions
|
||||
* Deactivate expired api keys
|
||||
*/
|
||||
async cleanDatabase() {
|
||||
// Remove invalid Podcast records
|
||||
|
|
@ -765,6 +780,60 @@ class Database {
|
|||
if (badSessionsRemoved > 0) {
|
||||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||
}
|
||||
|
||||
// Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt or if updatedAt is the same, remove arbitrary one)
|
||||
const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT mp1.id, mp1.mediaItemId
|
||||
FROM mediaProgresses mp1
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM mediaProgresses mp2
|
||||
WHERE mp2.mediaItemId = mp1.mediaItemId
|
||||
AND mp2.userId = mp1.userId
|
||||
AND (
|
||||
mp2.updatedAt > mp1.updatedAt
|
||||
OR (mp2.updatedAt = mp1.updatedAt AND mp2.id < mp1.id)
|
||||
)
|
||||
)`)
|
||||
for (const duplicateMediaProgress of duplicateMediaProgresses) {
|
||||
Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
|
||||
await this.mediaProgressModel.destroy({
|
||||
where: { id: duplicateMediaProgress.id }
|
||||
})
|
||||
}
|
||||
|
||||
// Remove expired Session records
|
||||
await this.cleanupExpiredSessions()
|
||||
|
||||
// Deactivate expired api keys
|
||||
await this.deactivateExpiredApiKeys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate expired api keys
|
||||
*/
|
||||
async deactivateExpiredApiKeys() {
|
||||
try {
|
||||
const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()
|
||||
if (affectedCount > 0) {
|
||||
Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions from the database
|
||||
*/
|
||||
async cleanupExpiredSessions() {
|
||||
try {
|
||||
const deletedCount = await this.sessionModel.cleanupExpiredSessions()
|
||||
if (deletedCount > 0) {
|
||||
Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async createTextSearchQuery(query) {
|
||||
|
|
|
|||
101
server/Server.js
101
server/Server.js
|
|
@ -12,6 +12,7 @@ const { version } = require('../package.json')
|
|||
|
||||
// Utils
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const { toNumber } = require('./utils/index')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
|
|
@ -84,12 +85,8 @@ class Server {
|
|||
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
|
||||
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
|
||||
} else {
|
||||
global.PodcastDownloadTimeout = 30000
|
||||
}
|
||||
global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000)
|
||||
global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24)
|
||||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
|
|
@ -159,14 +156,11 @@ class Server {
|
|||
}
|
||||
|
||||
await Database.init(false)
|
||||
// Create or set JWT secret in token manager
|
||||
await this.auth.tokenManager.initTokenSecret()
|
||||
|
||||
await Logger.logManager.init()
|
||||
|
||||
// Create token secret if does not exist (Added v2.1.0)
|
||||
if (!Database.serverSettings.tokenSecret) {
|
||||
await this.auth.initTokenSecret()
|
||||
}
|
||||
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await CacheManager.ensureCachePaths()
|
||||
|
||||
|
|
@ -223,6 +217,7 @@ class Server {
|
|||
|
||||
async start() {
|
||||
Logger.info('=== Starting Server ===')
|
||||
|
||||
this.initProcessEventListeners()
|
||||
await this.init()
|
||||
|
||||
|
|
@ -245,7 +240,7 @@ class Server {
|
|||
* Running in development allows cors to allow testing the mobile apps in the browser
|
||||
* or env variable ALLOW_CORS = '1'
|
||||
*/
|
||||
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
||||
if (global.AllowCors || Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
||||
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
||||
res.header('Access-Control-Allow-Origin', req.get('origin'))
|
||||
|
|
@ -266,7 +261,7 @@ class Server {
|
|||
// enable express-session
|
||||
app.use(
|
||||
expressSession({
|
||||
secret: global.ServerSettings.tokenSecret,
|
||||
secret: this.auth.tokenManager.TokenSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
|
|
@ -284,6 +279,7 @@ class Server {
|
|||
await this.auth.initPassportJs()
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
|
|
@ -310,16 +306,14 @@ class Server {
|
|||
})
|
||||
)
|
||||
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
|
||||
router.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// Skip JSON parsing for internal-api routes
|
||||
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
|
||||
|
||||
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
|
||||
router.use('/hls', this.hlsRouter.router)
|
||||
router.use('/public', this.publicRouter.router)
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
router.use(express.static(distPath))
|
||||
|
||||
// Static folder
|
||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
|
|
@ -339,32 +333,6 @@ class Server {
|
|||
// Auth routes
|
||||
await this.auth.initAuthRoutes(router)
|
||||
|
||||
// Client dynamic routes
|
||||
const dynamicRoutes = [
|
||||
'/item/:id',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/audiobook/:id/manage',
|
||||
'/library/:library',
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/narrators',
|
||||
'/library/:library/stats',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/library/:library/podcast/latest',
|
||||
'/library/:library/podcast/download-queue',
|
||||
'/config/users/:id',
|
||||
'/config/users/:id/sessions',
|
||||
'/config/item-metadata-utils/:id',
|
||||
'/collection/:id',
|
||||
'/playlist/:id',
|
||||
'/share/:slug'
|
||||
]
|
||||
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
|
||||
router.post('/init', (req, res) => {
|
||||
if (Database.hasRootUser) {
|
||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||
|
|
@ -395,6 +363,49 @@ class Server {
|
|||
})
|
||||
router.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||
|
||||
const ReactClientPath = process.env.REACT_CLIENT_PATH
|
||||
if (!ReactClientPath) {
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
router.use(express.static(distPath))
|
||||
|
||||
// Client dynamic routes
|
||||
const dynamicRoutes = [
|
||||
'/item/:id',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/audiobook/:id/manage',
|
||||
'/library/:library',
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/narrators',
|
||||
'/library/:library/stats',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/library/:library/podcast/latest',
|
||||
'/library/:library/podcast/download-queue',
|
||||
'/config/users/:id',
|
||||
'/config/users/:id/sessions',
|
||||
'/config/item-metadata-utils/:id',
|
||||
'/collection/:id',
|
||||
'/playlist/:id',
|
||||
'/share/:slug'
|
||||
]
|
||||
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
} else {
|
||||
// This is for using the experimental Next.js client
|
||||
Logger.info(`Using React client at ${ReactClientPath}`)
|
||||
const nextPath = Path.join(ReactClientPath, 'node_modules/next')
|
||||
const next = require(nextPath)
|
||||
const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })
|
||||
const handle = nextApp.getRequestHandler()
|
||||
await nextApp.prepare()
|
||||
router.get('*', (req, res) => handle(req, res))
|
||||
router.post('/internal-api/*', (req, res) => handle(req, res))
|
||||
}
|
||||
|
||||
const unixSocketPrefix = 'unix/'
|
||||
if (this.Host?.startsWith(unixSocketPrefix)) {
|
||||
const sockPath = this.Host.slice(unixSocketPrefix.length)
|
||||
|
|
@ -417,7 +428,7 @@ class Server {
|
|||
Logger.info(`[Server] Initializing new server`)
|
||||
const newRoot = req.body.newRoot
|
||||
const rootUsername = newRoot.username || 'root'
|
||||
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
|
||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const SocketIO = require('socket.io')
|
||||
const Logger = require('./Logger')
|
||||
const Database = require('./Database')
|
||||
const Auth = require('./Auth')
|
||||
const TokenManager = require('./auth/TokenManager')
|
||||
|
||||
/**
|
||||
* @typedef SocketClient
|
||||
|
|
@ -231,18 +231,22 @@ 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
|
||||
*
|
||||
* Sends event 'init' to the socket. For admins this contains an array of users online.
|
||||
* For failed authentication it sends event 'auth_failed' with a message
|
||||
*
|
||||
* @param {SocketIO.Socket} socket
|
||||
* @param {string} token JWT
|
||||
*/
|
||||
async authenticateSocket(socket, token) {
|
||||
// we don't use passport to authenticate the jwt we get over the socket connection.
|
||||
// it's easier to directly verify/decode it.
|
||||
const token_data = Auth.validateAccessToken(token)
|
||||
// TODO: Support API keys for web socket connections
|
||||
const token_data = TokenManager.validateAccessToken(token)
|
||||
|
||||
if (!token_data?.userId) {
|
||||
// Token invalid
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('invalid_token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
|
||||
// get the user via the id from the decoded jwt.
|
||||
|
|
@ -250,7 +254,11 @@ class SocketAuthority {
|
|||
if (!user) {
|
||||
// user not found
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('invalid_token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
if (!user.isActive) {
|
||||
Logger.error('Cannot validate socket - user is not active')
|
||||
return socket.emit('auth_failed', { message: 'Invalid user' })
|
||||
}
|
||||
|
||||
const client = this.clients[socket.id]
|
||||
|
|
@ -260,13 +268,18 @@ class SocketAuthority {
|
|||
}
|
||||
|
||||
if (client.user !== undefined) {
|
||||
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
|
||||
if (client.user.id === user.id) {
|
||||
// Allow re-authentication of a socket to the same user
|
||||
Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`)
|
||||
} else {
|
||||
// Allow re-authentication of a socket to a different user but shouldn't happen
|
||||
Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`)
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`)
|
||||
}
|
||||
|
||||
client.user = user
|
||||
|
||||
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
|
||||
|
|
|
|||
186
server/auth/LocalAuthStrategy.js
Normal file
186
server/auth/LocalAuthStrategy.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
const passport = require('passport')
|
||||
const LocalStrategy = require('../libs/passportLocal')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const bcrypt = require('../libs/bcryptjs')
|
||||
const requestIp = require('../libs/requestIp')
|
||||
|
||||
/**
|
||||
* Local authentication strategy using username/password
|
||||
*/
|
||||
class LocalAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'local'
|
||||
this.strategy = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the passport strategy instance
|
||||
* @returns {LocalStrategy}
|
||||
*/
|
||||
getStrategy() {
|
||||
if (!this.strategy) {
|
||||
this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this))
|
||||
}
|
||||
return this.strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
*/
|
||||
init() {
|
||||
passport.use(this.name, this.getStrategy())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
this.strategy = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user credentials
|
||||
* @param {import('express').Request} req
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {Function} done - Passport callback
|
||||
*/
|
||||
async verifyCredentials(req, username, password, done) {
|
||||
// Load the user given it's username
|
||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||
|
||||
if (!user?.isActive) {
|
||||
if (user) {
|
||||
this.logFailedLoginAttempt(req, user.username, 'User is not active')
|
||||
} else {
|
||||
this.logFailedLoginAttempt(req, username, 'User not found')
|
||||
}
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && !user.pash) {
|
||||
if (password) {
|
||||
// deny login
|
||||
this.logFailedLoginAttempt(req, user.username, 'Root user has no password set')
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
// approve login
|
||||
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||
|
||||
done(null, user)
|
||||
return
|
||||
} else if (!user.pash) {
|
||||
this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID')
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
// approve login
|
||||
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
// deny login
|
||||
this.logFailedLoginAttempt(req, user.username, 'Invalid password')
|
||||
done(null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempts
|
||||
* @param {import('express').Request} req
|
||||
* @param {string} username
|
||||
* @param {string} message
|
||||
*/
|
||||
logFailedLoginAttempt(req, username, message) {
|
||||
if (!req || !username || !message) return
|
||||
Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password with bcrypt
|
||||
* @param {string} password
|
||||
* @returns {Promise<string>} hash
|
||||
*/
|
||||
hashPassword(password) {
|
||||
return new Promise((resolve) => {
|
||||
bcrypt.hash(password, 8, (err, hash) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(hash)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with user's hashed password
|
||||
* @param {string} password
|
||||
* @param {import('../models/User')} user
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
return bcrypt.compare(password, user.pash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
* @param {import('../models/User')} user
|
||||
* @param {string} password
|
||||
* @param {string} newPassword
|
||||
*/
|
||||
async changePassword(user, password, newPassword) {
|
||||
// Only root can have an empty password
|
||||
if (user.type !== 'root' && !newPassword) {
|
||||
return {
|
||||
error: 'Invalid new password - Only root can have an empty password'
|
||||
}
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await this.comparePassword(password, user)
|
||||
if (!compare) {
|
||||
return {
|
||||
error: 'Invalid password'
|
||||
}
|
||||
}
|
||||
|
||||
let pw = ''
|
||||
if (newPassword) {
|
||||
pw = await this.hashPassword(newPassword)
|
||||
if (!pw) {
|
||||
return {
|
||||
error: 'Hash failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await user.update({ pash: pw })
|
||||
Logger.info(`[LocalAuth] User "${user.username}" changed password`)
|
||||
return {
|
||||
success: true
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error)
|
||||
return {
|
||||
error: 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocalAuthStrategy
|
||||
488
server/auth/OidcAuthStrategy.js
Normal file
488
server/auth/OidcAuthStrategy.js
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
const { Request, Response } = require('express')
|
||||
const passport = require('passport')
|
||||
const OpenIDClient = require('openid-client')
|
||||
const axios = require('axios')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* OpenID Connect authentication strategy
|
||||
*/
|
||||
class OidcAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'openid-client'
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the passport strategy instance
|
||||
* @returns {OpenIDClient.Strategy}
|
||||
*/
|
||||
getStrategy() {
|
||||
if (!this.strategy) {
|
||||
this.strategy = new OpenIDClient.Strategy(
|
||||
{
|
||||
client: this.getClient(),
|
||||
params: {
|
||||
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||
scope: this.getScope()
|
||||
}
|
||||
},
|
||||
this.verifyCallback.bind(this)
|
||||
)
|
||||
}
|
||||
return this.strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OpenID Connect client
|
||||
* @returns {OpenIDClient.Client}
|
||||
*/
|
||||
getClient() {
|
||||
if (!this.client) {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
throw new Error('OpenID Connect settings are not valid')
|
||||
}
|
||||
|
||||
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
||||
OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })
|
||||
|
||||
const openIdIssuerClient = new OpenIDClient.Issuer({
|
||||
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
||||
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
||||
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
||||
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
||||
jwks_uri: global.ServerSettings.authOpenIDJwksURL,
|
||||
end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
|
||||
}).Client
|
||||
|
||||
this.client = new openIdIssuerClient({
|
||||
client_id: global.ServerSettings.authOpenIDClientID,
|
||||
client_secret: global.ServerSettings.authOpenIDClientSecret,
|
||||
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
|
||||
})
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scope string for the OpenID Connect request
|
||||
* @returns {string}
|
||||
*/
|
||||
getScope() {
|
||||
let scope = 'openid profile email'
|
||||
if (global.ServerSettings.authOpenIDGroupClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
||||
}
|
||||
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
*/
|
||||
init() {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
|
||||
return
|
||||
}
|
||||
passport.use(this.name, this.getStrategy())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify callback for OpenID Connect authentication
|
||||
* @param {Object} tokenset
|
||||
* @param {Object} userinfo
|
||||
* @param {Function} done - Passport callback
|
||||
*/
|
||||
async verifyCallback(tokenset, userinfo, done) {
|
||||
try {
|
||||
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||
|
||||
if (!userinfo.sub) {
|
||||
throw new Error('Invalid userinfo, no sub')
|
||||
}
|
||||
|
||||
if (!this.validateGroupClaim(userinfo)) {
|
||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||
}
|
||||
|
||||
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo)
|
||||
|
||||
if (!user?.isActive) {
|
||||
throw new Error('User not active or not found')
|
||||
}
|
||||
|
||||
await this.setUserGroup(user, userinfo)
|
||||
await this.updateUserPermissions(user, userinfo)
|
||||
|
||||
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||
user.openid_id_token = tokenset.id_token
|
||||
|
||||
return done(null, user)
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||
return done(null, null, 'Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the presence and content of the group claim in userinfo.
|
||||
* @param {Object} userinfo
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validateGroupClaim(userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName)
|
||||
// Allow no group claim when configured like this
|
||||
return true
|
||||
|
||||
// If configured it must exist in userinfo
|
||||
if (!userinfo[groupClaimName]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user group based on group claim in userinfo.
|
||||
* @param {import('../models/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async setUserGroup(user, userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName)
|
||||
// No group claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||
|
||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||
|
||||
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||
if (userType) {
|
||||
if (user.type === 'root') {
|
||||
// Check OpenID Group
|
||||
if (userType !== 'admin') {
|
||||
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
||||
} else {
|
||||
// If root user is logging in via OpenID, we will not change the type
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (user.type !== userType) {
|
||||
Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
|
||||
user.type = userType
|
||||
await user.save()
|
||||
}
|
||||
} else {
|
||||
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user permissions based on the advanced permissions claim.
|
||||
* @param {import('../models/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async updateUserPermissions(user, userinfo) {
|
||||
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
||||
if (!absPermissionsClaim)
|
||||
// No advanced permissions claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (user.type === 'admin' || user.type === 'root') return
|
||||
|
||||
const absPermissions = userinfo[absPermissionsClaim]
|
||||
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||
|
||||
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE parameters for the authorization request
|
||||
* @param {Request} req
|
||||
* @param {boolean} isMobileFlow
|
||||
* @returns {Object|{error: string}}
|
||||
*/
|
||||
generatePkce(req, isMobileFlow) {
|
||||
if (isMobileFlow) {
|
||||
if (!req.query.code_challenge) {
|
||||
return {
|
||||
error: 'code_challenge required for mobile flow (PKCE)'
|
||||
}
|
||||
}
|
||||
if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
|
||||
return {
|
||||
error: 'Only S256 code_challenge_method method supported'
|
||||
}
|
||||
}
|
||||
return {
|
||||
code_challenge: req.query.code_challenge,
|
||||
code_challenge_method: req.query.code_challenge_method || 'S256'
|
||||
}
|
||||
} else {
|
||||
const code_verifier = OpenIDClient.generators.codeVerifier()
|
||||
const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
||||
return { code_challenge, code_challenge_method: 'S256', code_verifier }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a redirect URI is valid
|
||||
* @param {string} uri
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValidRedirectUri(uri) {
|
||||
// Check if the redirect_uri is in the whitelist
|
||||
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for OpenID Connect
|
||||
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
||||
* @param {Request} req
|
||||
* @returns {{ authorizationUrl: string }|{status: number, error: string}}
|
||||
*/
|
||||
getAuthorizationUrl(req) {
|
||||
const client = this.getClient()
|
||||
const strategy = this.getStrategy()
|
||||
const sessionKey = strategy._key
|
||||
|
||||
try {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||
|
||||
// Only allow code flow (for mobile clients)
|
||||
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||
Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid response_type, only code supported'
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a state on web flow or if no state supplied
|
||||
const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
|
||||
|
||||
// Redirect URL for the SSO provider
|
||||
let redirectUri
|
||||
if (isMobileFlow) {
|
||||
// Mobile required redirect uri
|
||||
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
||||
// where we will handle the redirect to it
|
||||
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
|
||||
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid redirect_uri'
|
||||
}
|
||||
}
|
||||
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
|
||||
// for the request to mobile-redirect and as such the session is not shared
|
||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||
} else {
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||
|
||||
if (req.query.state) {
|
||||
Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid state, not allowed on web flow'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the strategy's redirect_uri for this request
|
||||
strategy._params.redirect_uri = redirectUri
|
||||
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
||||
|
||||
const pkceData = this.generatePkce(req, isMobileFlow)
|
||||
if (pkceData.error) {
|
||||
return {
|
||||
status: 400,
|
||||
error: pkceData.error
|
||||
}
|
||||
}
|
||||
|
||||
req.session[sessionKey] = {
|
||||
...req.session[sessionKey],
|
||||
state: state,
|
||||
max_age: strategy._params.max_age,
|
||||
response_type: 'code',
|
||||
code_verifier: pkceData.code_verifier, // not null if web flow
|
||||
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||
}
|
||||
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
...strategy._params,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
response_type: 'code',
|
||||
scope: this.getScope(),
|
||||
code_challenge: pkceData.code_challenge,
|
||||
code_challenge_method: pkceData.code_challenge_method
|
||||
})
|
||||
|
||||
return {
|
||||
authorizationUrl,
|
||||
isMobileFlow
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`)
|
||||
return {
|
||||
status: 500,
|
||||
error: error.message || 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end session URL for logout
|
||||
* @param {Request} req
|
||||
* @param {string} idToken
|
||||
* @param {string} authMethod
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getEndSessionUrl(req, idToken, authMethod) {
|
||||
const client = this.getClient()
|
||||
|
||||
if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
|
||||
let postLogoutRedirectUri = null
|
||||
|
||||
if (authMethod === 'openid') {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const host = req.get('host')
|
||||
// TODO: ABS does currently not support subfolders for installation
|
||||
// If we want to support it we need to include a config for the serverurl
|
||||
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||
}
|
||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||
// the post_logout_redirect_uri parameter at all and for other providers
|
||||
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
|
||||
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
|
||||
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
|
||||
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
|
||||
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
|
||||
|
||||
return client.endSessionUrl({
|
||||
id_token_hint: idToken,
|
||||
post_logout_redirect_uri: postLogoutRedirectUri
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} OpenIdIssuerConfig
|
||||
* @property {string} issuer
|
||||
* @property {string} authorization_endpoint
|
||||
* @property {string} token_endpoint
|
||||
* @property {string} userinfo_endpoint
|
||||
* @property {string} end_session_endpoint
|
||||
* @property {string} jwks_uri
|
||||
* @property {string} id_token_signing_alg_values_supported
|
||||
*
|
||||
* Get OpenID Connect configuration from an issuer URL
|
||||
* @param {string} issuerUrl
|
||||
* @returns {Promise<OpenIdIssuerConfig|{status: number, error: string}>}
|
||||
*/
|
||||
async getIssuerConfig(issuerUrl) {
|
||||
// Strip trailing slash
|
||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||
|
||||
// Append config pathname and validate URL
|
||||
let configUrl = null
|
||||
try {
|
||||
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
||||
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
||||
throw new Error('Invalid pathname')
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
||||
return {
|
||||
status: 400,
|
||||
error: "Invalid request. Query param 'issuer' is invalid"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(configUrl.toString())
|
||||
return {
|
||||
issuer: data.issuer,
|
||||
authorization_endpoint: data.authorization_endpoint,
|
||||
token_endpoint: data.token_endpoint,
|
||||
userinfo_endpoint: data.userinfo_endpoint,
|
||||
end_session_endpoint: data.end_session_endpoint,
|
||||
jwks_uri: data.jwks_uri,
|
||||
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Failed to get openid configuration'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mobile redirect for OAuth2 callback
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
handleMobileRedirect(req, res) {
|
||||
try {
|
||||
// Extract the state parameter from the request
|
||||
const { state, code } = req.query
|
||||
|
||||
// Check if the state provided is in our list
|
||||
if (!state || !this.openIdAuthSession.has(state)) {
|
||||
Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
||||
return res.status(400).send('State parameter mismatch')
|
||||
}
|
||||
|
||||
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||
|
||||
if (!mobile_redirect_uri) {
|
||||
Logger.error('[OidcAuth] No redirect URI')
|
||||
return res.status(400).send('No redirect URI')
|
||||
}
|
||||
|
||||
this.openIdAuthSession.delete(state)
|
||||
|
||||
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
// Redirect to the overwrite URI saved in the map
|
||||
res.redirect(redirectUri)
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
|
||||
res.status(500).send('Internal Server Error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OidcAuthStrategy
|
||||
418
server/auth/TokenManager.js
Normal file
418
server/auth/TokenManager.js
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
const { Op } = require('sequelize')
|
||||
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const requestIp = require('../libs/requestIp')
|
||||
const jwt = require('../libs/jsonwebtoken')
|
||||
|
||||
class TokenManager {
|
||||
/** @type {string} JWT secret key */
|
||||
static TokenSecret = null
|
||||
|
||||
constructor() {
|
||||
/** @type {number} Refresh token expiry in seconds */
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
|
||||
/** @type {number} Access token expiry in seconds */
|
||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
|
||||
|
||||
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||
}
|
||||
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
||||
}
|
||||
}
|
||||
|
||||
get TokenSecret() {
|
||||
return TokenManager.TokenSecret
|
||||
}
|
||||
|
||||
/**
|
||||
* Token secret is used to sign and verify JWTs
|
||||
* Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set
|
||||
*/
|
||||
async initTokenSecret() {
|
||||
if (process.env.JWT_SECRET_KEY) {
|
||||
// Use user supplied token secret
|
||||
Logger.info('[TokenManager] JWT secret key set from ENV variable')
|
||||
TokenManager.TokenSecret = process.env.JWT_SECRET_KEY
|
||||
} else if (!Database.serverSettings.tokenSecret) {
|
||||
// Generate new token secret and store it on server settings
|
||||
Logger.info('[TokenManager] JWT secret key not found, generating one')
|
||||
TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
Database.serverSettings.tokenSecret = TokenManager.TokenSecret
|
||||
await Database.updateServerSettings()
|
||||
} else {
|
||||
// Use existing token secret from server settings
|
||||
TokenManager.TokenSecret = Database.serverSettings.tokenSecret
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the refresh token cookie
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {string} refreshToken
|
||||
*/
|
||||
setRefreshTokenCookie(req, res, refreshToken) {
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||
sameSite: 'lax',
|
||||
maxAge: this.RefreshTokenExpiry * 1000,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
* Used to authenticate socket connections
|
||||
* TODO: Support API keys for web socket connections
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {Object} tokens data
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, TokenManager.TokenSecret)
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a given user
|
||||
* TODO: Old method with no expiration
|
||||
* @deprecated
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
static generateAccessToken(user) {
|
||||
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate a jwt token for a given user
|
||||
* TODO: Old method with no expiration
|
||||
* @deprecated
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateAccessToken(user) {
|
||||
return TokenManager.generateAccessToken(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateTempAccessToken(user) {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
type: 'access'
|
||||
}
|
||||
const options = {
|
||||
expiresIn: this.AccessTokenExpiry
|
||||
}
|
||||
try {
|
||||
return jwt.sign(payload, TokenManager.TokenSecret, options)
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateRefreshToken(user) {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
type: 'refresh'
|
||||
}
|
||||
const options = {
|
||||
expiresIn: this.RefreshTokenExpiry
|
||||
}
|
||||
try {
|
||||
return jwt.sign(payload, TokenManager.TokenSecret, options)
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tokens and session for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @param {import('express').Request} req
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
||||
*/
|
||||
async createTokensAndSession(user, req) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
const userAgent = req.headers['user-agent']
|
||||
const accessToken = this.generateTempAccessToken(user)
|
||||
const refreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate expiration time for the refresh token
|
||||
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate tokens for a given session
|
||||
*
|
||||
* @param {import('../models/Session')} session
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
|
||||
*/
|
||||
async rotateTokensForSession(session, user, req, res) {
|
||||
// Generate new tokens
|
||||
const newAccessToken = this.generateTempAccessToken(user)
|
||||
const newRefreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate new expiration time
|
||||
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
// Update the session with the new refresh token and expiration
|
||||
session.refreshToken = newRefreshToken
|
||||
session.expiresAt = newExpiresAt
|
||||
await session.save()
|
||||
|
||||
// Set new refresh token cookie
|
||||
this.setRefreshTokenCookie(req, res, newRefreshToken)
|
||||
|
||||
return {
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the jwt is valid
|
||||
*
|
||||
* @param {Object} jwt_payload
|
||||
* @param {Function} done - passportjs callback
|
||||
*/
|
||||
async jwtAuthCheck(jwt_payload, done) {
|
||||
if (jwt_payload.type === 'api') {
|
||||
// Api key based authentication
|
||||
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
|
||||
|
||||
if (!apiKey?.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the api key is expired and deactivate it
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
done(null, null)
|
||||
|
||||
apiKey.isActive = false
|
||||
await apiKey.save()
|
||||
Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`)
|
||||
return
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(apiKey.userId)
|
||||
done(null, user)
|
||||
} else {
|
||||
// JWT based authentication
|
||||
|
||||
// Check if the jwt is expired
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// load user by id from the jwt token
|
||||
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
||||
|
||||
if (!user?.isActive) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Temporary flag to report old tokens to users
|
||||
// May be a better place for this but here means we dont have to decode the token again
|
||||
if (!jwt_payload.exp && !user.isOldToken) {
|
||||
Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
|
||||
user.isOldToken = true
|
||||
} else if (jwt_payload.exp && user.isOldToken !== undefined) {
|
||||
delete user.isOldToken
|
||||
}
|
||||
|
||||
// approve login
|
||||
done(null, user)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh token
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>}
|
||||
*/
|
||||
async handleRefreshToken(refreshToken, req, res) {
|
||||
try {
|
||||
// Verify the refresh token
|
||||
const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret)
|
||||
|
||||
if (decoded.type !== 'refresh') {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`)
|
||||
return {
|
||||
error: 'Invalid token type'
|
||||
}
|
||||
}
|
||||
|
||||
const session = await Database.sessionModel.findOne({
|
||||
where: { refreshToken: refreshToken }
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if session is expired in database
|
||||
if (session.expiresAt < new Date()) {
|
||||
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
|
||||
await session.destroy()
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
}
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(decoded.userId)
|
||||
if (!user?.isActive) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)
|
||||
return {
|
||||
error: 'User not found or inactive'
|
||||
}
|
||||
}
|
||||
|
||||
const newTokens = await this.rotateTokensForSession(session, user, req, res)
|
||||
return {
|
||||
accessToken: newTokens.accessToken,
|
||||
refreshToken: newTokens.refreshToken,
|
||||
user
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
Logger.info(`[TokenManager] Refresh token expired, cleaning up session`)
|
||||
|
||||
// Clean up the expired session from database
|
||||
try {
|
||||
await Database.sessionModel.destroy({
|
||||
where: { refreshToken: refreshToken }
|
||||
})
|
||||
Logger.info(`[TokenManager] Expired session cleaned up`)
|
||||
} catch (cleanupError) {
|
||||
Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
}
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[TokenManager] Refresh token error: ${error.message}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all JWT sessions for a given user
|
||||
* If user is current user and refresh token is valid, rotate tokens for the current session
|
||||
*
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
||||
*/
|
||||
async invalidateJwtSessionsForUser(user, req, res) {
|
||||
const currentRefreshToken = req.cookies.refresh_token
|
||||
if (req.user.id === user.id && currentRefreshToken) {
|
||||
// Current user is the same as the user to invalidate sessions for
|
||||
// So rotate token for current session
|
||||
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
|
||||
if (currentSession) {
|
||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
|
||||
|
||||
// Invalidate all sessions for the user except the current one
|
||||
await Database.sessionModel.destroy({
|
||||
where: {
|
||||
id: {
|
||||
[Op.ne]: currentSession.id
|
||||
},
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
return newTokens.accessToken
|
||||
} else {
|
||||
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Current user is not the same as the user to invalidate sessions for (or no refresh token)
|
||||
// So invalidate all sessions for the user
|
||||
await Database.sessionModel.destroy({ where: { userId: user.id } })
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a refresh token - used for logout
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async invalidateRefreshToken(refreshToken) {
|
||||
if (!refreshToken) {
|
||||
Logger.error(`[TokenManager] No refresh token provided to invalidate`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
|
||||
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TokenManager
|
||||
207
server/controllers/ApiKeyController.js
Normal file
207
server/controllers/ApiKeyController.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
const { Request, Response, NextFunction } = require('express')
|
||||
const uuidv4 = require('uuid').v4
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*/
|
||||
|
||||
class ApiKeyController {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* GET: /api/api-keys
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
const apiKeys = await Database.apiKeyModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username', 'type']
|
||||
},
|
||||
{
|
||||
model: Database.userModel,
|
||||
as: 'createdByUser',
|
||||
attributes: ['id', 'username', 'type']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return res.json({
|
||||
apiKeys: apiKeys.map((a) => a.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/api-keys
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
if (!req.body.name || typeof req.body.name !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) {
|
||||
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (!req.body.userId || typeof req.body.userId !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = await Database.userModel.getUserById(req.body.userId)
|
||||
if (!user) {
|
||||
Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
|
||||
const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn)
|
||||
|
||||
if (!apiKey) {
|
||||
Logger.error(`[ApiKeyController] create: Error generating API key`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
// Calculate expiration time for the api key
|
||||
const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null
|
||||
|
||||
const apiKeyInstance = await Database.apiKeyModel.create({
|
||||
id: keyId,
|
||||
name: req.body.name,
|
||||
expiresAt,
|
||||
userId: req.body.userId,
|
||||
isActive: !!req.body.isActive,
|
||||
createdByUserId: req.user.id
|
||||
})
|
||||
apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({
|
||||
attributes: ['id', 'username', 'type']
|
||||
})
|
||||
|
||||
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||
return res.json({
|
||||
apiKey: {
|
||||
apiKey, // Actual key only shown to user on creation
|
||||
...apiKeyInstance.toJSON()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/api-keys/:id
|
||||
* Only isActive and userId can be updated because name and expiresIn are in the JWT
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {
|
||||
include: {
|
||||
model: Database.userModel
|
||||
}
|
||||
})
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
// Only root user can update root user API keys
|
||||
if (apiKey.user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
let hasUpdates = false
|
||||
if (req.body.userId !== undefined) {
|
||||
if (typeof req.body.userId !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = await Database.userModel.getUserById(req.body.userId)
|
||||
if (!user) {
|
||||
Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (apiKey.userId !== req.body.userId) {
|
||||
apiKey.userId = req.body.userId
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.isActive !== undefined) {
|
||||
if (typeof req.body.isActive !== 'boolean') {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (apiKey.isActive !== req.body.isActive) {
|
||||
apiKey.isActive = req.body.isActive
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
await apiKey.save()
|
||||
apiKey.dataValues.user = await apiKey.getUser({
|
||||
attributes: ['id', 'username', 'type']
|
||||
})
|
||||
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||
} else {
|
||||
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
apiKey: apiKey.toJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/api-keys/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await apiKey.destroy()
|
||||
Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`)
|
||||
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[ApiKeyController] Non-admin user "${req.user.username}" attempting to access api keys`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ApiKeyController()
|
||||
|
|
@ -84,49 +84,73 @@ class FileSystemController {
|
|||
*/
|
||||
async checkPathExists(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.error(`[FileSystemController] Non-admin user "${req.user.username}" attempting to check path exists`)
|
||||
Logger.error(`[FileSystemController] User "${req.user.username}" without upload permissions attempting to check path exists`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const { filepath, directory, folderPath } = req.body
|
||||
const { directory, folderPath } = req.body
|
||||
if (!directory?.length || typeof directory !== 'string' || !folderPath?.length || typeof folderPath !== 'string') {
|
||||
Logger.error(`[FileSystemController] Invalid request body: ${JSON.stringify(req.body)}`)
|
||||
return res.status(400).json({
|
||||
error: 'Invalid request body'
|
||||
})
|
||||
}
|
||||
|
||||
if (!filepath?.length || typeof filepath !== 'string') {
|
||||
// Check that library folder exists
|
||||
const libraryFolder = await Database.libraryFolderModel.findOne({
|
||||
where: {
|
||||
path: folderPath
|
||||
}
|
||||
})
|
||||
|
||||
if (!libraryFolder) {
|
||||
Logger.error(`[FileSystemController] Library folder not found: ${folderPath}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (!req.user.checkCanAccessLibrary(libraryFolder.libraryId)) {
|
||||
Logger.error(`[FileSystemController] User "${req.user.username}" attempting to check path exists for library "${libraryFolder.libraryId}" without access`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
let filepath = Path.join(libraryFolder.path, directory)
|
||||
filepath = fileUtils.filePathToPOSIX(filepath)
|
||||
|
||||
// Ensure filepath is inside library folder (prevents directory traversal)
|
||||
if (!filepath.startsWith(libraryFolder.path)) {
|
||||
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const exists = await fs.pathExists(filepath)
|
||||
|
||||
if (exists) {
|
||||
if (await fs.pathExists(filepath)) {
|
||||
return res.json({
|
||||
exists: true
|
||||
})
|
||||
}
|
||||
|
||||
// If directory and folderPath are passed in, check if a library item exists in a subdirectory
|
||||
// Check if a library item exists in a subdirectory
|
||||
// See: https://github.com/advplyr/audiobookshelf/issues/4146
|
||||
if (typeof directory === 'string' && typeof folderPath === 'string' && directory.length > 0 && folderPath.length > 0) {
|
||||
const cleanedDirectory = directory.split('/').filter(Boolean).join('/')
|
||||
if (cleanedDirectory.includes('/')) {
|
||||
// Can only be 2 levels deep
|
||||
const possiblePaths = []
|
||||
const subdir = Path.dirname(directory)
|
||||
possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, subdir)))
|
||||
if (subdir.includes('/')) {
|
||||
possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, Path.dirname(subdir))))
|
||||
}
|
||||
const cleanedDirectory = directory.split('/').filter(Boolean).join('/')
|
||||
if (cleanedDirectory.includes('/')) {
|
||||
// Can only be 2 levels deep
|
||||
const possiblePaths = []
|
||||
const subdir = Path.dirname(directory)
|
||||
possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, subdir)))
|
||||
if (subdir.includes('/')) {
|
||||
possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, Path.dirname(subdir))))
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.findOne({
|
||||
where: {
|
||||
path: possiblePaths
|
||||
}
|
||||
const libraryItem = await Database.libraryItemModel.findOne({
|
||||
where: {
|
||||
path: possiblePaths
|
||||
}
|
||||
})
|
||||
|
||||
if (libraryItem) {
|
||||
return res.json({
|
||||
exists: true,
|
||||
libraryItemTitle: libraryItem.title
|
||||
})
|
||||
|
||||
if (libraryItem) {
|
||||
return res.json({
|
||||
exists: true,
|
||||
libraryItemTitle: libraryItem.title
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -273,12 +273,24 @@ class MeController {
|
|||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
updatePassword(req, res) {
|
||||
async updatePassword(req, res) {
|
||||
if (req.user.isGuest) {
|
||||
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
this.auth.userChangePassword(req, res)
|
||||
|
||||
const { password, newPassword } = req.body
|
||||
if ((typeof password !== 'string' && password !== null) || (typeof newPassword !== 'string' && newPassword !== null)) {
|
||||
return res.status(400).send('Missing or invalid password or new password')
|
||||
}
|
||||
|
||||
const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)
|
||||
|
||||
if (result.error) {
|
||||
return res.status(400).send(result.error)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -438,7 +450,7 @@ class MeController {
|
|||
if (updated) {
|
||||
await Database.updateSetting(Database.emailSettings)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', {
|
||||
ereaderDevices: Database.emailSettings.ereaderDevices
|
||||
ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)
|
||||
})
|
||||
}
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ class MiscController {
|
|||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
|
||||
if (!req.user.checkCanAccessLibrary(library.id)) {
|
||||
Logger.error(`[MiscController] User "${req.user.username}" attempting to upload to library "${library.id}" without access`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const folder = library.libraryFolders.find((fold) => fold.id === folderId)
|
||||
if (!folder) {
|
||||
return res.status(404).send('Folder not found')
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const fs = require('../libs/fsExtra')
|
|||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const { validateUrl } = require('../utils/index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
|
|
@ -404,6 +405,15 @@ class PodcastController {
|
|||
const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
|
||||
for (const key in req.body) {
|
||||
if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {
|
||||
// Sanitize description HTML
|
||||
if (key === 'description' && req.body[key]) {
|
||||
const sanitizedDescription = htmlSanitizer.sanitize(req.body[key])
|
||||
if (sanitizedDescription !== req.body[key]) {
|
||||
Logger.debug(`[PodcastController] Sanitized description from "${req.body[key]}" to "${sanitizedDescription}"`)
|
||||
req.body[key] = sanitizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
||||
updatePayload[key] = req.body[key]
|
||||
|
|
|
|||
|
|
@ -57,26 +57,24 @@ class SessionController {
|
|||
}
|
||||
|
||||
let where = null
|
||||
const include = [
|
||||
{
|
||||
model: Database.models.device
|
||||
}
|
||||
]
|
||||
|
||||
if (userId) {
|
||||
where = {
|
||||
userId
|
||||
}
|
||||
} else {
|
||||
include.push({
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username']
|
||||
})
|
||||
}
|
||||
|
||||
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
|
||||
where,
|
||||
include,
|
||||
include: [
|
||||
{
|
||||
model: Database.deviceModel
|
||||
},
|
||||
{
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username']
|
||||
}
|
||||
],
|
||||
order: [[orderKey, orderDesc]],
|
||||
limit: itemsPerPage,
|
||||
offset: itemsPerPage * page
|
||||
|
|
@ -290,7 +288,12 @@ class SessionController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const audioTrack = playbackSession.audioTracks.find((t) => t.index === audioTrackIndex)
|
||||
let audioTrack = playbackSession.audioTracks.find((t) => toNumber(t.index, 1) === audioTrackIndex)
|
||||
|
||||
// Support clients passing 0 or 1 for podcast episode audio track index (handles old episodes pre-v2.21.0 having null index)
|
||||
if (!audioTrack && playbackSession.mediaType === 'podcast' && audioTrackIndex === 0) {
|
||||
audioTrack = playbackSession.audioTracks[0]
|
||||
}
|
||||
if (!audioTrack) {
|
||||
Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`)
|
||||
return res.sendStatus(404)
|
||||
|
|
|
|||
|
|
@ -127,8 +127,8 @@ class UserController {
|
|||
}
|
||||
|
||||
const userId = uuidv4()
|
||||
const pash = await this.auth.hashPass(req.body.password)
|
||||
const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
||||
const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
|
||||
const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
||||
const userType = req.body.type || 'user'
|
||||
|
||||
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
|
||||
|
|
@ -237,6 +237,7 @@ class UserController {
|
|||
|
||||
let hasUpdates = false
|
||||
let shouldUpdateToken = false
|
||||
let shouldInvalidateJwtSessions = false
|
||||
// When changing username create a new API token
|
||||
if (updatePayload.username && updatePayload.username !== user.username) {
|
||||
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
|
||||
|
|
@ -245,12 +246,13 @@ class UserController {
|
|||
}
|
||||
user.username = updatePayload.username
|
||||
shouldUpdateToken = true
|
||||
shouldInvalidateJwtSessions = true
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Updating password
|
||||
if (updatePayload.password) {
|
||||
user.pash = await this.auth.hashPass(updatePayload.password)
|
||||
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
|
|
@ -325,9 +327,24 @@ class UserController {
|
|||
|
||||
if (hasUpdates) {
|
||||
if (shouldUpdateToken) {
|
||||
user.token = await this.auth.generateAccessToken(user)
|
||||
user.token = this.auth.generateAccessToken(user)
|
||||
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
||||
}
|
||||
|
||||
// Handle JWT session invalidation for username changes
|
||||
if (shouldInvalidateJwtSessions) {
|
||||
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||
if (newAccessToken) {
|
||||
user.accessToken = newAccessToken
|
||||
// Refresh tokens are only returned for mobile clients
|
||||
// Mobile apps currently do not use this API endpoint so always set to null
|
||||
user.refreshToken = null
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
|
||||
} else {
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
await user.save()
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
|
||||
}
|
||||
|
|
@ -422,7 +439,16 @@ class UserController {
|
|||
const page = toNumber(req.query.page, 0)
|
||||
|
||||
const start = page * itemsPerPage
|
||||
const sessions = listeningSessions.slice(start, start + itemsPerPage)
|
||||
// Map user to sessions to match the format of the sessions endpoint
|
||||
const sessions = listeningSessions.slice(start, start + itemsPerPage).map((session) => {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
id: req.reqUser.id,
|
||||
username: req.reqUser.username
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const payload = {
|
||||
total: listeningSessions.length,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const FantLab = require('../providers/FantLab')
|
|||
const AudiobookCovers = require('../providers/AudiobookCovers')
|
||||
const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
|
||||
const Logger = require('../Logger')
|
||||
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
|
||||
const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
class BookFinder {
|
||||
|
|
@ -385,7 +385,11 @@ class BookFinder {
|
|||
|
||||
if (!title) return books
|
||||
|
||||
books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
const isTitleAsin = isValidASIN(title.toUpperCase())
|
||||
|
||||
let actualTitleQuery = title
|
||||
let actualAuthorQuery = author
|
||||
books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
|
||||
if (!books.length && maxFuzzySearches > 0) {
|
||||
// Normalize title and author
|
||||
|
|
@ -408,19 +412,26 @@ class BookFinder {
|
|||
for (const titlePart of titleParts) titleCandidates.add(titlePart)
|
||||
titleCandidates = titleCandidates.getCandidates()
|
||||
for (const titleCandidate of titleCandidates) {
|
||||
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
|
||||
if (titleCandidate == actualTitleQuery && authorCandidate == actualAuthorQuery) continue // We already tried this
|
||||
if (++numFuzzySearches > maxFuzzySearches) break loop_author
|
||||
books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
actualTitleQuery = titleCandidate
|
||||
actualAuthorQuery = authorCandidate
|
||||
books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||
if (books.length) break loop_author
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (books.length) {
|
||||
const resultsHaveDuration = provider.startsWith('audible')
|
||||
if (resultsHaveDuration && libraryItem?.media?.duration) {
|
||||
const libraryItemDurationMinutes = libraryItem.media.duration / 60
|
||||
// If provider results have duration, sort by ascendinge duration difference from libraryItem
|
||||
const isAudibleProvider = provider.startsWith('audible')
|
||||
const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null
|
||||
|
||||
books.forEach((book) => {
|
||||
if (typeof book !== 'object' || !isAudibleProvider) return
|
||||
book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin)
|
||||
})
|
||||
|
||||
if (isAudibleProvider && libraryItemDurationMinutes) {
|
||||
books.sort((a, b) => {
|
||||
const aDuration = a.duration || Number.POSITIVE_INFINITY
|
||||
const bDuration = b.duration || Number.POSITIVE_INFINITY
|
||||
|
|
@ -433,6 +444,120 @@ class BookFinder {
|
|||
return books
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match confidence score for a book
|
||||
* @param {Object} book - The book object to calculate confidence for
|
||||
* @param {number|null} libraryItemDurationMinutes - Duration of library item in minutes
|
||||
* @param {string} actualTitleQuery - Actual title query
|
||||
* @param {string} actualAuthorQuery - Actual author query
|
||||
* @param {boolean} isTitleAsin - Whether the title is an ASIN
|
||||
* @returns {number|null} - Match confidence score or null if not applicable
|
||||
*/
|
||||
calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) {
|
||||
// ASIN results are always a match
|
||||
if (isTitleAsin) return 1.0
|
||||
|
||||
let durationScore
|
||||
if (libraryItemDurationMinutes && typeof book.duration === 'number') {
|
||||
const durationDiff = Math.abs(book.duration - libraryItemDurationMinutes)
|
||||
// Duration scores:
|
||||
// diff | score
|
||||
// 0 | 1.0
|
||||
// 1 | 1.0
|
||||
// 2 | 0.9
|
||||
// 3 | 0.8
|
||||
// 4 | 0.7
|
||||
// 5 | 0.6
|
||||
// 6 | 0.48
|
||||
// 7 | 0.36
|
||||
// 8 | 0.24
|
||||
// 9 | 0.12
|
||||
// 10 | 0.0
|
||||
if (durationDiff <= 1) {
|
||||
// Covers durationDiff = 0 for score 1.0
|
||||
durationScore = 1.0
|
||||
} else if (durationDiff <= 5) {
|
||||
// (1, 5] - Score from 1.0 down to 0.6
|
||||
// Linearly interpolates between (1, 1.0) and (5, 0.6)
|
||||
// Equation: y = 1.0 - 0.08 * x
|
||||
durationScore = 1.1 - 0.1 * durationDiff
|
||||
} else if (durationDiff <= 10) {
|
||||
// (5, 10] - Score from 0.6 down to 0.0
|
||||
// Linearly interpolates between (5, 0.6) and (10, 0.0)
|
||||
// Equation: y = 1.2 - 0.12 * x
|
||||
durationScore = 1.2 - 0.12 * durationDiff
|
||||
} else {
|
||||
// durationDiff > 10 - Score is 0.0
|
||||
durationScore = 0.0
|
||||
}
|
||||
Logger.debug(`[BookFinder] Duration diff: ${durationDiff}, durationScore: ${durationScore}`)
|
||||
} else {
|
||||
// Default score if library item duration or book duration is not available
|
||||
durationScore = 0.1
|
||||
}
|
||||
|
||||
const calculateTitleScore = (titleQuery, book, keepSubtitle = false) => {
|
||||
const cleanTitle = cleanTitleForCompares(book.title || '', keepSubtitle)
|
||||
const cleanSubtitle = keepSubtitle && book.subtitle ? `: ${book.subtitle}` : ''
|
||||
const normBookTitle = `${cleanTitle}${cleanSubtitle}`
|
||||
const normTitleQuery = cleanTitleForCompares(titleQuery, keepSubtitle)
|
||||
const titleSimilarity = levenshteinSimilarity(normTitleQuery, normBookTitle)
|
||||
Logger.debug(`[BookFinder] keepSubtitle: ${keepSubtitle}, normBookTitle: ${normBookTitle}, normTitleQuery: ${normTitleQuery}, titleSimilarity: ${titleSimilarity}`)
|
||||
return titleSimilarity
|
||||
}
|
||||
const titleQueryHasSubtitle = hasSubtitle(actualTitleQuery)
|
||||
const titleScore = calculateTitleScore(actualTitleQuery, book, titleQueryHasSubtitle)
|
||||
|
||||
let authorScore
|
||||
const normAuthorQuery = cleanAuthorForCompares(actualAuthorQuery)
|
||||
const normBookAuthor = cleanAuthorForCompares(book.author || '')
|
||||
if (!normAuthorQuery) {
|
||||
// Original query had no author
|
||||
authorScore = 1.0 // Neutral score
|
||||
} else {
|
||||
// Original query HAS an author (cleanedQueryAuthorForScore is not empty)
|
||||
if (normBookAuthor) {
|
||||
const bookAuthorParts = normBookAuthor.split(',').map((name) => name.trim().toLowerCase())
|
||||
// Filter out empty parts that might result from ", ," or trailing/leading commas
|
||||
const validBookAuthorParts = bookAuthorParts.filter((p) => p.length > 0)
|
||||
|
||||
if (validBookAuthorParts.length === 0) {
|
||||
// Book author string was present but effectively empty (e.g. ",,")
|
||||
// Since cleanedQueryAuthorForScore is non-empty here, this is a mismatch.
|
||||
authorScore = 0.0
|
||||
} else {
|
||||
let maxPartScore = levenshteinSimilarity(normAuthorQuery, normBookAuthor)
|
||||
Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, normBookAuthor: ${normBookAuthor}, similarity: ${maxPartScore}`)
|
||||
if (validBookAuthorParts.length > 1 || normBookAuthor.includes(',')) {
|
||||
validBookAuthorParts.forEach((part) => {
|
||||
// part is guaranteed to be non-empty here
|
||||
// cleanedQueryAuthorForScore is also guaranteed non-empty here.
|
||||
// levenshteinDistance lowercases by default, but part is already lowercased.
|
||||
const similarity = levenshteinSimilarity(normAuthorQuery, part)
|
||||
Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, bookAuthorPart: ${part}, similarity: ${similarity}`)
|
||||
const currentPartScore = similarity
|
||||
maxPartScore = Math.max(maxPartScore, currentPartScore)
|
||||
})
|
||||
}
|
||||
authorScore = maxPartScore
|
||||
}
|
||||
} else {
|
||||
// Book has NO author (or not a string, or empty string)
|
||||
// Query has an author (cleanedQueryAuthorForScore is non-empty), book does not.
|
||||
authorScore = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
const W_DURATION = 0.7
|
||||
const W_TITLE = 0.2
|
||||
const W_AUTHOR = 0.1
|
||||
|
||||
Logger.debug(`[BookFinder] Duration score: ${durationScore}, Title score: ${titleScore}, Author score: ${authorScore}`)
|
||||
const confidence = W_DURATION * durationScore + W_TITLE * titleScore + W_AUTHOR * authorScore
|
||||
Logger.debug(`[BookFinder] Confidence: ${confidence}`)
|
||||
return Math.max(0, Math.min(1, confidence))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for books
|
||||
*
|
||||
|
|
@ -464,6 +589,7 @@ class BookFinder {
|
|||
} else {
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
if (book.description) {
|
||||
book.description = htmlSanitizer.sanitize(book.description)
|
||||
|
|
@ -505,6 +631,9 @@ class BookFinder {
|
|||
}
|
||||
module.exports = new BookFinder()
|
||||
|
||||
function hasSubtitle(title) {
|
||||
return title.includes(':') || title.includes(' - ')
|
||||
}
|
||||
function stripSubtitle(title) {
|
||||
if (title.includes(':')) {
|
||||
return title.split(':')[0].trim()
|
||||
|
|
@ -523,12 +652,12 @@ function replaceAccentedChars(str) {
|
|||
}
|
||||
}
|
||||
|
||||
function cleanTitleForCompares(title) {
|
||||
function cleanTitleForCompares(title, keepSubtitle = false) {
|
||||
if (!title) return ''
|
||||
title = stripRedundantSpaces(title)
|
||||
|
||||
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
|
||||
let stripped = stripSubtitle(title)
|
||||
let stripped = keepSubtitle ? title : stripSubtitle(title)
|
||||
|
||||
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
|
||||
let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
|
||||
|
|
|
|||
13
server/libs/fusejs/index.js
Normal file
13
server/libs/fusejs/index.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -203,7 +203,15 @@ class AbMergeManager {
|
|||
// Move library item tracks to cache
|
||||
for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {
|
||||
const trackFilename = Path.basename(trackPath)
|
||||
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
||||
let moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
||||
|
||||
// If the track is the same as the temp file, we need to rename it to avoid overwriting it
|
||||
if (task.data.tempFilepath === moveToPath) {
|
||||
const trackExtname = Path.extname(task.data.tempFilepath)
|
||||
const newTrackFilename = Path.basename(task.data.tempFilepath, trackExtname) + '.backup' + trackExtname
|
||||
moveToPath = Path.join(task.data.itemCachePath, newTrackFilename)
|
||||
}
|
||||
|
||||
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
||||
if (index === 0) {
|
||||
// copy the first track to the cache directory
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ class CronManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize open session cleanup cron
|
||||
* Initialize open session & auth session cleanup cron
|
||||
* Runs every day at 00:30
|
||||
* Closes open share sessions that have not been updated in 24 hours
|
||||
* Closes open playback sessions that have not been updated in 36 hours
|
||||
* Cleans up expired auth sessions
|
||||
* Deactivates expired api keys
|
||||
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
||||
*/
|
||||
initOpenSessionCleanupCron() {
|
||||
|
|
@ -42,6 +44,8 @@ class CronManager {
|
|||
Logger.debug('[CronManager] Open session cleanup cron executing')
|
||||
ShareManager.closeStaleOpenShareSessions()
|
||||
await this.playbackSessionManager.closeStaleOpenSessions()
|
||||
await Database.cleanupExpiredSessions()
|
||||
await Database.deactivateExpiredApiKeys()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,54 @@ class NotificationManager {
|
|||
this.triggerNotification('onBackupCompleted', eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles scheduled episode download RSS feed request failed
|
||||
*
|
||||
* @param {string} feedUrl
|
||||
* @param {number} numFailed
|
||||
* @param {string} title
|
||||
*/
|
||||
async onRSSFeedFailed(feedUrl, numFailed, title) {
|
||||
if (!Database.notificationSettings.isUseable) return
|
||||
|
||||
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedFailed')) {
|
||||
Logger.debug(`[NotificationManager] onRSSFeedFailed: No active notifications`)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[NotificationManager] onRSSFeedFailed: RSS feed request failed for ${feedUrl}`)
|
||||
const eventData = {
|
||||
feedUrl: feedUrl,
|
||||
numFailed: numFailed || 0,
|
||||
title: title || 'Unknown Title'
|
||||
}
|
||||
this.triggerNotification('onRSSFeedFailed', eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles scheduled episode downloads disabled due to too many failed attempts
|
||||
*
|
||||
* @param {string} feedUrl
|
||||
* @param {number} numFailed
|
||||
* @param {string} title
|
||||
*/
|
||||
async onRSSFeedDisabled(feedUrl, numFailed, title) {
|
||||
if (!Database.notificationSettings.isUseable) return
|
||||
|
||||
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedDisabled')) {
|
||||
Logger.debug(`[NotificationManager] onRSSFeedDisabled: No active notifications`)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[NotificationManager] onRSSFeedDisabled: Podcast scheduled episode download disabled due to ${numFailed} failed requests for ${feedUrl}`)
|
||||
const eventData = {
|
||||
feedUrl: feedUrl,
|
||||
numFailed: numFailed || 0,
|
||||
title: title || 'Unknown Title'
|
||||
}
|
||||
this.triggerNotification('onRSSFeedDisabled', eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} errorMsg
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class PlaybackSessionManager {
|
|||
|
||||
const syncResults = []
|
||||
for (const sessionJson of sessions) {
|
||||
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
||||
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id}) (updatedAt: ${sessionJson.updatedAt})`)
|
||||
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
|
||||
syncResults.push(result)
|
||||
}
|
||||
|
|
@ -230,9 +230,9 @@ class PlaybackSessionManager {
|
|||
let userProgressForItem = user.getMediaProgress(mediaItemId)
|
||||
if (userProgressForItem) {
|
||||
if (userProgressForItem.updatedAt.valueOf() > session.updatedAt) {
|
||||
Logger.debug(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently`)
|
||||
Logger.info(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently (${userProgressForItem.updatedAt.valueOf()} > ${session.updatedAt}) (incoming currentTime: ${session.currentTime}) (current currentTime: ${userProgressForItem.currentTime})`)
|
||||
} else {
|
||||
Logger.debug(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
|
||||
Logger.info(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
|
||||
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
||||
libraryItemId: libraryItem.id,
|
||||
episodeId: session.episodeId,
|
||||
|
|
@ -246,7 +246,7 @@ class PlaybackSessionManager {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
|
||||
Logger.info(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
|
||||
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
||||
libraryItemId: libraryItem.id,
|
||||
episodeId: session.episodeId,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class PodcastManager {
|
|||
this.currentDownload = null
|
||||
|
||||
this.failedCheckMap = {}
|
||||
this.MaxFailedEpisodeChecks = 24
|
||||
this.MaxFailedEpisodeChecks = global.MaxFailedEpisodeChecks
|
||||
}
|
||||
|
||||
getEpisodeDownloadsInQueue(libraryItemId) {
|
||||
|
|
@ -345,12 +345,14 @@ class PodcastManager {
|
|||
// Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download
|
||||
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||
this.failedCheckMap[libraryItem.id]++
|
||||
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||
if (this.MaxFailedEpisodeChecks !== 0 && this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
|
||||
void NotificationManager.onRSSFeedDisabled(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)
|
||||
libraryItem.media.autoDownloadEpisodes = false
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
} else {
|
||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
|
||||
void NotificationManager.onRSSFeedFailed(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)
|
||||
}
|
||||
} else if (newEpisodes.length) {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
|
|
@ -384,7 +386,17 @@ class PodcastManager {
|
|||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return null
|
||||
}
|
||||
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
|
||||
const feed = await Promise.race([
|
||||
getPodcastFeed(podcastLibraryItem.media.feedURL),
|
||||
new Promise((_, reject) =>
|
||||
// The added second is to make sure that axios can fail first and only falls back later
|
||||
setTimeout(() => reject(new Error('Timeout. getPodcastFeed seemed to timeout but not triggering the timeout.')), global.PodcastDownloadTimeout + 1000)
|
||||
)
|
||||
]).catch((error) => {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes failed to fetch feed for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id}):`, error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!feed?.episodes) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return null
|
||||
|
|
|
|||
163
server/migrations/v2.26.0-create-auth-tables.js
Normal file
163
server/migrations/v2.26.0-create-auth-tables.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.26.0'
|
||||
const migrationName = `${migrationVersion}-create-auth-tables`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration creates a sessions table and apiKeys table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
logger.info(`${loggerPrefix} table "sessions" already exists`)
|
||||
} else {
|
||||
// Create table
|
||||
logger.info(`${loggerPrefix} creating table "sessions"`)
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('sessions', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
ipAddress: DataTypes.STRING,
|
||||
userAgent: DataTypes.STRING,
|
||||
refreshToken: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
allowNull: false,
|
||||
onDelete: 'CASCADE'
|
||||
}
|
||||
})
|
||||
logger.info(`${loggerPrefix} created table "sessions"`)
|
||||
}
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('apiKeys')) {
|
||||
logger.info(`${loggerPrefix} table "apiKeys" already exists`)
|
||||
} else {
|
||||
// Create table
|
||||
logger.info(`${loggerPrefix} creating table "apiKeys"`)
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('apiKeys', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
expiresAt: DataTypes.DATE,
|
||||
lastUsedAt: DataTypes.DATE,
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
permissions: DataTypes.JSON,
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users',
|
||||
as: 'createdByUser'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'SET NULL'
|
||||
}
|
||||
})
|
||||
logger.info(`${loggerPrefix} created table "apiKeys"`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the sessions table and apiKeys table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
logger.info(`${loggerPrefix} dropping table "sessions"`)
|
||||
// Drop table
|
||||
await queryInterface.dropTable('sessions')
|
||||
logger.info(`${loggerPrefix} dropped table "sessions"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} table "sessions" does not exist`)
|
||||
}
|
||||
|
||||
if (await queryInterface.tableExists('apiKeys')) {
|
||||
logger.info(`${loggerPrefix} dropping table "apiKeys"`)
|
||||
await queryInterface.dropTable('apiKeys')
|
||||
logger.info(`${loggerPrefix} dropped table "apiKeys"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} table "apiKeys" does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
272
server/models/ApiKey.js
Normal file
272
server/models/ApiKey.js
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiKeyPermissions
|
||||
* @property {boolean} download
|
||||
* @property {boolean} update
|
||||
* @property {boolean} delete
|
||||
* @property {boolean} upload
|
||||
* @property {boolean} createEreader
|
||||
* @property {boolean} accessAllLibraries
|
||||
* @property {boolean} accessAllTags
|
||||
* @property {boolean} accessExplicitContent
|
||||
* @property {boolean} selectedTagsNotAccessible
|
||||
* @property {string[]} librariesAccessible
|
||||
* @property {string[]} itemTagsSelected
|
||||
*/
|
||||
|
||||
class ApiKeyCache {
|
||||
constructor() {
|
||||
this.cache = new LRUCache({ max: 100 })
|
||||
}
|
||||
|
||||
getById(id) {
|
||||
const apiKey = this.cache.get(id)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
set(apiKey) {
|
||||
apiKey.fromCache = true
|
||||
this.cache.set(apiKey.id, apiKey)
|
||||
}
|
||||
|
||||
delete(apiKeyId) {
|
||||
this.cache.delete(apiKeyId)
|
||||
}
|
||||
|
||||
maybeInvalidate(apiKey) {
|
||||
if (!apiKey.fromCache) this.delete(apiKey.id)
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeyCache = new ApiKeyCache()
|
||||
|
||||
class ApiKey extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {Date} */
|
||||
this.lastUsedAt
|
||||
/** @type {boolean} */
|
||||
this.isActive
|
||||
/** @type {ApiKeyPermissions} */
|
||||
this.permissions
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {UUIDV4} */
|
||||
this.createdByUserId
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./User').User} */
|
||||
this.user
|
||||
}
|
||||
|
||||
/**
|
||||
* Same properties as User.getDefaultPermissions
|
||||
* @returns {ApiKeyPermissions}
|
||||
*/
|
||||
static getDefaultPermissions() {
|
||||
return {
|
||||
download: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
upload: true,
|
||||
createEreader: true,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: true,
|
||||
selectedTagsNotAccessible: false, // Inverts itemTagsSelected
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge permissions from request with default permissions
|
||||
* @param {ApiKeyPermissions} reqPermissions
|
||||
* @returns {ApiKeyPermissions}
|
||||
*/
|
||||
static mergePermissionsWithDefault(reqPermissions) {
|
||||
const permissions = this.getDefaultPermissions()
|
||||
|
||||
if (!reqPermissions || typeof reqPermissions !== 'object') {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`)
|
||||
return permissions
|
||||
}
|
||||
|
||||
for (const key in reqPermissions) {
|
||||
if (reqPermissions[key] === undefined) {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'librariesAccessible' || key === 'itemTagsSelected') {
|
||||
if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`)
|
||||
continue
|
||||
}
|
||||
|
||||
permissions[key] = reqPermissions[key]
|
||||
} else if (typeof reqPermissions[key] !== 'boolean') {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`)
|
||||
continue
|
||||
}
|
||||
|
||||
permissions[key] = reqPermissions[key]
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate expired api keys
|
||||
* @returns {Promise<number>} Number of api keys affected
|
||||
*/
|
||||
static async deactivateExpiredApiKeys() {
|
||||
const [affectedCount] = await ApiKey.update(
|
||||
{
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
where: {
|
||||
isActive: true,
|
||||
expiresAt: {
|
||||
[Op.lt]: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return affectedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new api key
|
||||
* @param {string} tokenSecret
|
||||
* @param {string} keyId
|
||||
* @param {string} name
|
||||
* @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
static async generateApiKey(tokenSecret, keyId, name, expiresIn) {
|
||||
const options = {}
|
||||
if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) {
|
||||
options.expiresIn = expiresIn
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
jwt.sign(
|
||||
{
|
||||
keyId,
|
||||
name,
|
||||
type: 'api'
|
||||
},
|
||||
tokenSecret,
|
||||
options,
|
||||
(err, token) => {
|
||||
if (err) {
|
||||
Logger.error(`[ApiKey] Error generating API key: ${err}`)
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an api key by id, from cache or database
|
||||
* @param {string} apiKeyId
|
||||
* @returns {Promise<ApiKey | null>}
|
||||
*/
|
||||
static async getById(apiKeyId) {
|
||||
if (!apiKeyId) return null
|
||||
|
||||
const cachedApiKey = apiKeyCache.getById(apiKeyId)
|
||||
if (cachedApiKey) return cachedApiKey
|
||||
|
||||
const apiKey = await ApiKey.findByPk(apiKeyId)
|
||||
if (!apiKey) return null
|
||||
|
||||
apiKeyCache.set(apiKey)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
expiresAt: DataTypes.DATE,
|
||||
lastUsedAt: DataTypes.DATE,
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
permissions: DataTypes.JSON
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'apiKey'
|
||||
}
|
||||
)
|
||||
|
||||
const { user } = sequelize.models
|
||||
user.hasMany(ApiKey, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
ApiKey.belongsTo(user)
|
||||
|
||||
user.hasMany(ApiKey, {
|
||||
foreignKey: 'createdByUserId',
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' })
|
||||
}
|
||||
|
||||
async update(values, options) {
|
||||
apiKeyCache.maybeInvalidate(this)
|
||||
return await super.update(values, options)
|
||||
}
|
||||
|
||||
async save(options) {
|
||||
apiKeyCache.maybeInvalidate(this)
|
||||
return await super.save(options)
|
||||
}
|
||||
|
||||
async destroy(options) {
|
||||
apiKeyCache.delete(this.id)
|
||||
await super.destroy(options)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiKey
|
||||
|
|
@ -377,8 +377,17 @@ class Book extends Model {
|
|||
if (typeof payload.metadata[key] == 'number') {
|
||||
payload.metadata[key] = String(payload.metadata[key])
|
||||
}
|
||||
|
||||
|
||||
if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && this[key] !== payload.metadata[key]) {
|
||||
// Sanitize description HTML
|
||||
if (key === 'description' && payload.metadata[key]) {
|
||||
const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key])
|
||||
if (sanitizedDescription !== payload.metadata[key]) {
|
||||
Logger.debug(`[Book] "${this.title}" Sanitized description from "${payload.metadata[key]}" to "${sanitizedDescription}"`)
|
||||
payload.metadata[key] = sanitizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
this[key] = payload.metadata[key] || null
|
||||
|
||||
if (key === 'title') {
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ class MediaProgress extends Model {
|
|||
* @param {import('./User').ProgressUpdatePayload} progressPayload
|
||||
* @returns {Promise<MediaProgress>}
|
||||
*/
|
||||
applyProgressUpdate(progressPayload) {
|
||||
async applyProgressUpdate(progressPayload) {
|
||||
if (!this.extraData) this.extraData = {}
|
||||
if (progressPayload.isFinished !== undefined) {
|
||||
if (progressPayload.isFinished && !this.isFinished) {
|
||||
|
|
@ -222,13 +222,13 @@ class MediaProgress extends Model {
|
|||
const markAsFinishedPercentComplete = Number(progressPayload.markAsFinishedPercentComplete) / 100
|
||||
shouldMarkAsFinished = markAsFinishedPercentComplete < this.progress
|
||||
if (shouldMarkAsFinished) {
|
||||
Logger.debug(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete}`)
|
||||
Logger.info(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete} (media item ${this.mediaItemId})`)
|
||||
}
|
||||
} else {
|
||||
const markAsFinishedTimeRemaining = isNullOrNaN(progressPayload.markAsFinishedTimeRemaining) ? 10 : Number(progressPayload.markAsFinishedTimeRemaining)
|
||||
shouldMarkAsFinished = timeRemaining < markAsFinishedTimeRemaining
|
||||
if (shouldMarkAsFinished) {
|
||||
Logger.debug(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds`)
|
||||
Logger.info(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds (media item ${this.mediaItemId})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -243,13 +243,23 @@ class MediaProgress extends Model {
|
|||
this.finishedAt = null
|
||||
}
|
||||
|
||||
await this.save()
|
||||
|
||||
// For local sync
|
||||
if (progressPayload.lastUpdate) {
|
||||
this.updatedAt = progressPayload.lastUpdate
|
||||
this.changed('updatedAt', true)
|
||||
if (isNaN(new Date(progressPayload.lastUpdate))) {
|
||||
Logger.warn(`[MediaProgress] Invalid date provided for lastUpdate: ${progressPayload.lastUpdate} (media item ${this.mediaItemId})`)
|
||||
} else {
|
||||
const escapedDate = this.sequelize.escape(new Date(progressPayload.lastUpdate))
|
||||
Logger.info(`[MediaProgress] Manually setting updatedAt to ${escapedDate} (media item ${this.mediaItemId})`)
|
||||
|
||||
await this.sequelize.query(`UPDATE "mediaProgresses" SET "updatedAt" = ${escapedDate} WHERE "id" = '${this.id}'`)
|
||||
|
||||
await this.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return this.save({ silent: !!progressPayload.lastUpdate })
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const { DataTypes, Model } = require('sequelize')
|
|||
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
||||
const Logger = require('../Logger')
|
||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
/**
|
||||
* @typedef PodcastExpandedProperties
|
||||
|
|
@ -220,6 +221,15 @@ class Podcast extends Model {
|
|||
newKey = 'itunesPageURL'
|
||||
}
|
||||
if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && payload.metadata[key] !== this[newKey]) {
|
||||
// Sanitize description HTML
|
||||
if (key === 'description' && payload.metadata[key]) {
|
||||
const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key])
|
||||
if (sanitizedDescription !== payload.metadata[key]) {
|
||||
Logger.debug(`[Podcast] "${this.title}" Sanitized description from "${payload.metadata[key]}" to "${sanitizedDescription}"`)
|
||||
payload.metadata[key] = sanitizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
this[newKey] = payload.metadata[key] || null
|
||||
|
||||
if (key === 'title') {
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ class PodcastEpisode extends Model {
|
|||
const track = structuredClone(this.audioFile)
|
||||
track.startOffset = 0
|
||||
track.title = this.audioFile.metadata.filename
|
||||
track.index = 1 // Podcast episodes only have one track
|
||||
track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}`
|
||||
return track
|
||||
}
|
||||
|
|
|
|||
88
server/models/Session.js
Normal file
88
server/models/Session.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
|
||||
class Session extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.ipAddress
|
||||
/** @type {string} */
|
||||
this.userAgent
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./User').User} */
|
||||
this.user
|
||||
}
|
||||
|
||||
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
|
||||
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions from the database
|
||||
* @returns {Promise<number>} Number of sessions deleted
|
||||
*/
|
||||
static async cleanupExpiredSessions() {
|
||||
const deletedCount = await Session.destroy({
|
||||
where: {
|
||||
expiresAt: {
|
||||
[Op.lt]: new Date()
|
||||
}
|
||||
}
|
||||
})
|
||||
return deletedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
ipAddress: DataTypes.STRING,
|
||||
userAgent: DataTypes.STRING,
|
||||
refreshToken: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'session'
|
||||
}
|
||||
)
|
||||
|
||||
const { user } = sequelize.models
|
||||
user.hasMany(Session, {
|
||||
onDelete: 'CASCADE',
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
}
|
||||
})
|
||||
Session.belongsTo(user)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Session
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
const uuidv4 = require('uuid').v4
|
||||
const sequelize = require('sequelize')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const { isNullOrNaN } = require('../utils')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
const TokenManager = require('../auth/TokenManager')
|
||||
|
||||
class UserCache {
|
||||
constructor() {
|
||||
|
|
@ -190,7 +192,7 @@ class User extends Model {
|
|||
static async createRootUser(username, pash, auth) {
|
||||
const userId = uuidv4()
|
||||
|
||||
const token = await auth.generateAccessToken({ id: userId, username })
|
||||
const token = auth.generateAccessToken({ id: userId, username })
|
||||
|
||||
const newUser = {
|
||||
id: userId,
|
||||
|
|
@ -209,18 +211,106 @@ class User extends Model {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create user from openid userinfo
|
||||
* Finds an existing user by OpenID subject identifier, or by email/username based on server settings,
|
||||
* or creates a new user if configured to do so.
|
||||
*
|
||||
* @param {Object} userinfo
|
||||
* @param {import('../Auth')} auth
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static async createUserFromOpenIdUserInfo(userinfo, auth) {
|
||||
static async findOrCreateUserFromOpenIdUserInfo(userinfo) {
|
||||
let user = await this.getUserByOpenIDSub(userinfo.sub)
|
||||
|
||||
// Matched by sub
|
||||
if (user) {
|
||||
Logger.debug(`[User] openid: User found by sub`)
|
||||
return user
|
||||
}
|
||||
|
||||
// Match existing user by email
|
||||
if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') {
|
||||
if (userinfo.email) {
|
||||
// Only disallow when email_verified explicitly set to false (allow both if not set or true)
|
||||
if (userinfo.email_verified === false) {
|
||||
Logger.warn(`[User] openid: User not found and email "${userinfo.email}" is not verified`)
|
||||
return null
|
||||
} else {
|
||||
Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`)
|
||||
user = await this.getUserByEmail(userinfo.email)
|
||||
|
||||
if (user?.authOpenIDSub) {
|
||||
Logger.warn(`[User] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
|
||||
return null // User is linked to a different OpenID subject; do not proceed.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.warn(`[User] openid: User not found and no email in userinfo`)
|
||||
// We deny login, because if the admin whishes to match email, it makes sense to require it
|
||||
return null
|
||||
}
|
||||
}
|
||||
// Match existing user by username
|
||||
else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') {
|
||||
let username
|
||||
|
||||
if (userinfo.preferred_username) {
|
||||
Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`)
|
||||
username = userinfo.preferred_username
|
||||
} else if (userinfo.username) {
|
||||
Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`)
|
||||
username = userinfo.username
|
||||
} else {
|
||||
Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`)
|
||||
return null
|
||||
}
|
||||
|
||||
user = await this.getUserByUsername(username)
|
||||
|
||||
if (user?.authOpenIDSub) {
|
||||
Logger.warn(`[User] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`)
|
||||
return null // User is linked to a different OpenID subject; do not proceed.
|
||||
}
|
||||
}
|
||||
|
||||
// Found existing user via email or username
|
||||
if (user) {
|
||||
if (!user.isActive) {
|
||||
Logger.warn(`[User] openid: User found but is not active`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Update user with OpenID sub
|
||||
if (!user.extraData) user.extraData = {}
|
||||
user.extraData.authOpenIDSub = userinfo.sub
|
||||
user.changed('extraData', true)
|
||||
await user.save()
|
||||
|
||||
Logger.debug(`[User] openid: User found by email/username`)
|
||||
return user
|
||||
}
|
||||
|
||||
// If no existing user was matched, auto-register if configured
|
||||
if (global.ServerSettings.authOpenIDAutoRegister) {
|
||||
Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
|
||||
user = await this.createUserFromOpenIdUserInfo(userinfo)
|
||||
return user
|
||||
}
|
||||
|
||||
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user from openid userinfo
|
||||
* @param {Object} userinfo
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static async createUserFromOpenIdUserInfo(userinfo) {
|
||||
const userId = uuidv4()
|
||||
// TODO: Ensure username is unique?
|
||||
const username = userinfo.preferred_username || userinfo.name || userinfo.sub
|
||||
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
|
||||
|
||||
const token = await auth.generateAccessToken({ id: userId, username })
|
||||
const token = TokenManager.generateAccessToken({ id: userId, username })
|
||||
|
||||
const newUser = {
|
||||
id: userId,
|
||||
|
|
@ -520,7 +610,11 @@ class User extends Model {
|
|||
username: this.username,
|
||||
email: this.email,
|
||||
type: this.type,
|
||||
// TODO: Old non-expiring token
|
||||
token: this.type === 'root' && hideRootToken ? '' : this.token,
|
||||
// TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck
|
||||
// Necessary to detect apps using old tokens that no longer match the old token stored on the user
|
||||
isOldToken: this.isOldToken,
|
||||
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
|
||||
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
|
||||
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const User = require('../../models/User')
|
|||
class ServerSettings {
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
/** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */
|
||||
this.tokenSecret = null
|
||||
|
||||
// Scanner
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class Audible {
|
|||
}
|
||||
|
||||
cleanResult(item) {
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType, isbn } = item
|
||||
|
||||
const series = []
|
||||
if (seriesPrimary) {
|
||||
|
|
@ -70,6 +70,7 @@ class Audible {
|
|||
description: summary || null,
|
||||
cover: image,
|
||||
asin,
|
||||
isbn,
|
||||
genres: genresFiltered.length ? genresFiltered : null,
|
||||
tags: tagsFiltered.length ? tagsFiltered.join(', ') : null,
|
||||
series: series.length ? series : null,
|
||||
|
|
|
|||
|
|
@ -52,9 +52,7 @@ class FantLab {
|
|||
return []
|
||||
})
|
||||
|
||||
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => {
|
||||
return resArray.filter((res) => res)
|
||||
})
|
||||
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => resArray.filter(Boolean))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,6 +81,10 @@ class FantLab {
|
|||
return null
|
||||
})
|
||||
|
||||
if (!bookData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.cleanBookData(bookData, timeout)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP
|
|||
const MiscController = require('../controllers/MiscController')
|
||||
const ShareController = require('../controllers/ShareController')
|
||||
const StatsController = require('../controllers/StatsController')
|
||||
const ApiKeyController = require('../controllers/ApiKeyController')
|
||||
|
||||
class ApiRouter {
|
||||
constructor(Server) {
|
||||
|
|
@ -181,7 +182,7 @@ class ApiRouter {
|
|||
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
|
||||
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
|
||||
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
|
||||
this.router.patch('/me/password', MeController.updatePassword.bind(this))
|
||||
this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this))
|
||||
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
|
||||
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
|
||||
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
|
||||
|
|
@ -325,6 +326,14 @@ class ApiRouter {
|
|||
this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))
|
||||
this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this))
|
||||
|
||||
//
|
||||
// API Key Routes
|
||||
//
|
||||
this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this))
|
||||
this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this))
|
||||
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
|
||||
this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))
|
||||
|
||||
//
|
||||
// Misc Routes
|
||||
//
|
||||
|
|
|
|||
|
|
@ -206,6 +206,11 @@ class LibraryItemScanner {
|
|||
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
|
||||
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
|
||||
|
||||
if (!libraryItemScanData.libraryFiles.length) {
|
||||
Logger.info(`[LibraryItemScanner] Library item at path "${libraryItemPath}" has no files - ignoring`)
|
||||
return null
|
||||
}
|
||||
|
||||
const scanLogger = new ScanLogger()
|
||||
scanLogger.verbose = true
|
||||
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
|
||||
|
|
|
|||
|
|
@ -606,6 +606,11 @@ class LibraryScanner {
|
|||
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`)
|
||||
continue
|
||||
} else if (!(await fs.pathExists(fullPath))) {
|
||||
Logger.info(`[LibraryScanner] File update group "${itemDir}" does not exist - ignoring`)
|
||||
|
||||
itemGroupingResults[itemDir] = ScanResult.NOTHING
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if a library item is a subdirectory of this dir
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ class Scanner {
|
|||
|
||||
let numEpisodesUpdated = 0
|
||||
for (const episode of episodesToQuickMatch) {
|
||||
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title)
|
||||
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title, 0.1)
|
||||
if (episodeMatches?.length) {
|
||||
const wasUpdated = await this.updateEpisodeWithMatch(episode, episodeMatches[0].episode, options)
|
||||
if (wasUpdated) numEpisodesUpdated++
|
||||
|
|
|
|||
|
|
@ -103,18 +103,39 @@ module.exports.resizeImage = resizeImage
|
|||
*/
|
||||
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||
return new Promise(async (resolve) => {
|
||||
const response = await axios({
|
||||
url: podcastEpisodeDownload.url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
headers: {
|
||||
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
|
||||
},
|
||||
timeout: global.PodcastDownloadTimeout
|
||||
}).catch((error) => {
|
||||
Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error)
|
||||
return null
|
||||
})
|
||||
// Some podcasts fail due to user agent strings
|
||||
// See: https://github.com/advplyr/audiobookshelf/issues/3246 (requires iTMS user agent)
|
||||
// See: https://github.com/advplyr/audiobookshelf/issues/4401 (requires no iTMS user agent)
|
||||
const userAgents = ['audiobookshelf (+https://audiobookshelf.org; like iTMS)', 'audiobookshelf (+https://audiobookshelf.org)']
|
||||
|
||||
let response = null
|
||||
let lastError = null
|
||||
|
||||
for (const userAgent of userAgents) {
|
||||
try {
|
||||
response = await axios({
|
||||
url: podcastEpisodeDownload.url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
headers: {
|
||||
'User-Agent': userAgent
|
||||
},
|
||||
timeout: global.PodcastDownloadTimeout
|
||||
})
|
||||
|
||||
Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`)
|
||||
break
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
Logger.warn(`[ffmpegHelpers] Failed to download podcast episode with User-Agent "${userAgent}" for url "${podcastEpisodeDownload.url}"`, error.message)
|
||||
|
||||
// If this is the last attempt, log the full error
|
||||
if (userAgent === userAgents[userAgents.length - 1]) {
|
||||
Logger.error(`[ffmpegHelpers] All User-Agent attempts failed for url "${podcastEpisodeDownload.url}"`, lastError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return resolve({
|
||||
success: false
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ function getIno(path) {
|
|||
.stat(path, { bigint: true })
|
||||
.then((data) => String(data.ino))
|
||||
.catch((err) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, err)
|
||||
Logger.warn(`[Utils] Failed to get ino for path "${path}"`, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
|||
}
|
||||
module.exports.levenshteinDistance = levenshteinDistance
|
||||
|
||||
const levenshteinSimilarity = (str1, str2, caseSensitive = false) => {
|
||||
const distance = levenshteinDistance(str1, str2, caseSensitive)
|
||||
const maxLength = Math.max(str1.length, str2.length)
|
||||
if (maxLength === 0) return 1
|
||||
return 1 - distance / maxLength
|
||||
}
|
||||
module.exports.levenshteinSimilarity = levenshteinSimilarity
|
||||
|
||||
module.exports.isObject = (val) => {
|
||||
return val !== null && typeof val === 'object'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,38 @@ module.exports.notificationData = {
|
|||
errorMsg: 'Example error message'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'onRSSFeedFailed',
|
||||
requiresLibrary: true,
|
||||
description: 'Triggered when the RSS feed request fails for an automatic episode download',
|
||||
descriptionKey: 'NotificationOnRSSFeedFailedDescription',
|
||||
variables: ['feedUrl', 'numFailed', 'title'],
|
||||
defaults: {
|
||||
title: 'RSS Feed Request Failed',
|
||||
body: 'Failed to request RSS feed for {{title}}.\nFeed URL: {{feedUrl}}\nNumber of failed attempts: {{numFailed}}'
|
||||
},
|
||||
testData: {
|
||||
title: 'Test RSS Feed',
|
||||
feedUrl: 'https://example.com/rss',
|
||||
numFailed: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'onRSSFeedDisabled',
|
||||
requiresLibrary: true,
|
||||
description: 'Triggered when automatic episode downloads are disabled due to too many failed attempts',
|
||||
descriptionKey: 'NotificationOnRSSFeedDisabledDescription',
|
||||
variables: ['feedUrl', 'numFailed', 'title'],
|
||||
defaults: {
|
||||
title: 'Podcast Episode Download Schedule Disabled',
|
||||
body: 'Automatic episode downloads for {{title}} have been disabled due to too many failed RSS feed requests.\nFeed URL: {{feedUrl}}\nNumber of failed attempts: {{numFailed}}'
|
||||
},
|
||||
testData: {
|
||||
title: 'Test RSS Feed',
|
||||
feedUrl: 'https://example.com/rss',
|
||||
numFailed: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'onTest',
|
||||
requiresLibrary: false,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
const axios = require('axios')
|
||||
const ssrfFilter = require('ssrf-req-filter')
|
||||
const Logger = require('../Logger')
|
||||
const { xmlToJSON, levenshteinDistance, timestampToSeconds } = require('./index')
|
||||
const { xmlToJSON, timestampToSeconds } = require('./index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
const Fuse = require('../libs/fusejs')
|
||||
|
||||
/**
|
||||
* @typedef RssPodcastChapter
|
||||
|
|
@ -24,6 +25,7 @@ const htmlSanitizer = require('../utils/htmlSanitizer')
|
|||
* @property {string} episode
|
||||
* @property {string} author
|
||||
* @property {string} duration
|
||||
* @property {number|null} durationSeconds - Parsed from duration string if duration is valid
|
||||
* @property {string} explicit
|
||||
* @property {number} publishedAt - Unix timestamp
|
||||
* @property {{ url: string, type?: string, length?: string }} enclosure
|
||||
|
|
@ -205,7 +207,7 @@ function extractEpisodeData(item) {
|
|||
} else if (typeof guidItem?._ === 'string') {
|
||||
episode.guid = guidItem._
|
||||
} else {
|
||||
Logger.error(`[podcastUtils] Invalid guid ${item['guid']} for ${episode.enclosure.url}`)
|
||||
Logger.error(`[podcastUtils] Invalid guid for ${episode.enclosure.url}`, item['guid'])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -216,8 +218,9 @@ function extractEpisodeData(item) {
|
|||
})
|
||||
|
||||
// Extract psc:chapters if duration is set
|
||||
let episodeDuration = !isNaN(episode.duration) ? timestampToSeconds(episode.duration) : null
|
||||
if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episodeDuration) {
|
||||
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null
|
||||
|
||||
if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episode.durationSeconds) {
|
||||
// Example chapter:
|
||||
// {"id":0,"start":0,"end":43.004286,"title":"chapter 1"}
|
||||
|
||||
|
|
@ -243,7 +246,7 @@ function extractEpisodeData(item) {
|
|||
} else {
|
||||
episode.chapters = cleanedChapters.map((chapter, index) => {
|
||||
const nextChapter = cleanedChapters[index + 1]
|
||||
const end = nextChapter ? nextChapter.start : episodeDuration
|
||||
const end = nextChapter ? nextChapter.start : episode.durationSeconds
|
||||
return {
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
|
|
@ -272,6 +275,7 @@ function cleanEpisodeData(data) {
|
|||
episode: data.episode || '',
|
||||
author: data.author || '',
|
||||
duration: data.duration || '',
|
||||
durationSeconds: data.durationSeconds || null,
|
||||
explicit: data.explicit || '',
|
||||
publishedAt,
|
||||
enclosure: data.enclosure,
|
||||
|
|
@ -407,7 +411,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||
})
|
||||
}
|
||||
|
||||
// Return array of episodes ordered by closest match (Levenshtein distance of 6 or less)
|
||||
// Return array of episodes ordered by closest match using fuse.js
|
||||
module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {
|
||||
const feed = await this.getPodcastFeed(feedUrl).catch(() => {
|
||||
return null
|
||||
|
|
@ -420,32 +424,29 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {
|
|||
*
|
||||
* @param {RssPodcast} feed
|
||||
* @param {string} searchTitle
|
||||
* @returns {Array<{ episode: RssPodcastEpisode, levenshtein: number }>}
|
||||
* @param {number} [threshold=0.4] - 0.0 for perfect match, 1.0 for match anything
|
||||
* @returns {Array<{ episode: RssPodcastEpisode }>}
|
||||
*/
|
||||
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
||||
searchTitle = searchTitle.toLowerCase().trim()
|
||||
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle, threshold = 0.4) => {
|
||||
if (!feed?.episodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fuseOptions = {
|
||||
ignoreDiacritics: true,
|
||||
threshold,
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.7 }, // prefer match in title
|
||||
{ name: 'subtitle', weight: 0.3 }
|
||||
]
|
||||
}
|
||||
const fuse = new Fuse(feed.episodes, fuseOptions)
|
||||
|
||||
const matches = []
|
||||
feed.episodes.forEach((ep) => {
|
||||
if (!ep.title) return
|
||||
const epTitle = ep.title.toLowerCase().trim()
|
||||
if (epTitle === searchTitle) {
|
||||
matches.push({
|
||||
episode: ep,
|
||||
levenshtein: 0
|
||||
})
|
||||
} else {
|
||||
const levenshtein = levenshteinDistance(searchTitle, epTitle, true)
|
||||
if (levenshtein <= 6 && epTitle.length > levenshtein) {
|
||||
matches.push({
|
||||
episode: ep,
|
||||
levenshtein
|
||||
})
|
||||
}
|
||||
}
|
||||
fuse.search(searchTitle).forEach((match) => {
|
||||
matches.push({
|
||||
episode: match.item
|
||||
})
|
||||
})
|
||||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||
return matches
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ module.exports = {
|
|||
mediaWhere['$series.id$'] = null
|
||||
} else if (group === 'abridged') {
|
||||
mediaWhere['abridged'] = true
|
||||
} else if (group === 'explicit') {
|
||||
mediaWhere['explicit'] = true
|
||||
} else if (['genres', 'tags', 'narrators'].includes(group)) {
|
||||
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
|
|
@ -251,6 +253,15 @@ module.exports = {
|
|||
*/
|
||||
getOrder(sortBy, sortDesc, collapseseries) {
|
||||
const dir = sortDesc ? 'DESC' : 'ASC'
|
||||
|
||||
const getTitleOrder = () => {
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
return [Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]
|
||||
} else {
|
||||
return [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]
|
||||
}
|
||||
}
|
||||
|
||||
if (sortBy === 'addedAt') {
|
||||
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
|
||||
} else if (sortBy === 'size') {
|
||||
|
|
@ -264,19 +275,16 @@ module.exports = {
|
|||
} else if (sortBy === 'media.metadata.publishedYear') {
|
||||
return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]]
|
||||
} else if (sortBy === 'media.metadata.authorNameLF') {
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir]]
|
||||
// Sort by author name last first, secondary sort by title
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||
} else if (sortBy === 'media.metadata.authorName') {
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir]]
|
||||
// Sort by author name first last, secondary sort by title
|
||||
return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], getTitleOrder()]
|
||||
} else if (sortBy === 'media.metadata.title') {
|
||||
if (collapseseries) {
|
||||
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
|
||||
}
|
||||
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
||||
} else {
|
||||
return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
|
||||
}
|
||||
return [getTitleOrder()]
|
||||
} else if (sortBy === 'sequence') {
|
||||
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
|
||||
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ module.exports = {
|
|||
replacements.filterValue = value
|
||||
} else if (group === 'languages') {
|
||||
mediaWhere['language'] = value
|
||||
} else if (group === 'explicit') {
|
||||
mediaWhere['explicit'] = true
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -149,11 +151,12 @@ module.exports = {
|
|||
libraryId
|
||||
}
|
||||
const libraryItemIncludes = []
|
||||
if (includeRSSFeed) {
|
||||
if (filterGroup === 'feed-open' || includeRSSFeed) {
|
||||
const rssFeedRequired = filterGroup === 'feed-open'
|
||||
libraryItemIncludes.push({
|
||||
model: Database.feedModel,
|
||||
required: filterGroup === 'feed-open',
|
||||
separate: true
|
||||
required: rssFeedRequired,
|
||||
separate: !rssFeedRequired
|
||||
})
|
||||
}
|
||||
if (filterGroup === 'issues') {
|
||||
|
|
|
|||
82
server/utils/rateLimiterFactory.js
Normal file
82
server/utils/rateLimiterFactory.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit')
|
||||
const Logger = require('../Logger')
|
||||
const requestIp = require('../libs/requestIp')
|
||||
|
||||
/**
|
||||
* Factory for creating authentication rate limiters
|
||||
*/
|
||||
class RateLimiterFactory {
|
||||
static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes
|
||||
static DEFAULT_MAX = 40 // 40 attempts
|
||||
|
||||
constructor() {
|
||||
this.authRateLimiter = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiter
|
||||
* @returns {RateLimitRequestHandler}
|
||||
*/
|
||||
getAuthRateLimiter() {
|
||||
if (this.authRateLimiter) {
|
||||
return this.authRateLimiter
|
||||
}
|
||||
|
||||
// Disable by setting max to 0
|
||||
if (process.env.RATE_LIMIT_AUTH_MAX === '0') {
|
||||
this.authRateLimiter = (req, res, next) => next()
|
||||
Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`)
|
||||
return this.authRateLimiter
|
||||
}
|
||||
|
||||
let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS
|
||||
if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) {
|
||||
windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW)
|
||||
if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) {
|
||||
Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`)
|
||||
}
|
||||
}
|
||||
|
||||
let max = RateLimiterFactory.DEFAULT_MAX
|
||||
if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) {
|
||||
max = parseInt(process.env.RATE_LIMIT_AUTH_MAX)
|
||||
if (max !== RateLimiterFactory.DEFAULT_MAX) {
|
||||
Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`)
|
||||
}
|
||||
}
|
||||
|
||||
let message = 'Too many authentication requests'
|
||||
if (process.env.RATE_LIMIT_AUTH_MESSAGE) {
|
||||
message = process.env.RATE_LIMIT_AUTH_MESSAGE
|
||||
}
|
||||
|
||||
this.authRateLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
// Override keyGenerator to handle proxy IPs
|
||||
return requestIp.getClientIp(req) || req.ip
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const userAgent = req.get('User-Agent') || 'Unknown'
|
||||
const endpoint = req.path
|
||||
const method = req.method
|
||||
const ip = requestIp.getClientIp(req) || req.ip
|
||||
|
||||
Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`)
|
||||
|
||||
res.status(429).json({
|
||||
error: message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`)
|
||||
|
||||
return this.authRateLimiter
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RateLimiterFactory()
|
||||
Loading…
Add table
Add a link
Reference in a new issue