Merge branch 'advplyr:master' into master

This commit is contained in:
MagiX13 2025-07-28 21:08:52 +02:00 committed by GitHub
commit 8321ba6291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 6154 additions and 1541 deletions

File diff suppressed because it is too large Load diff

View file

@ -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) {

View file

@ -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)

View file

@ -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

View 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

View 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
View 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

View 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()

View file

@ -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
})
}
}
}

View file

@ -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({

View file

@ -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')

View file

@ -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]

View file

@ -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)

View file

@ -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,

View file

@ -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, '')

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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()
})
}

View file

@ -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

View file

@ -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,

View file

@ -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

View 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
View 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

View file

@ -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') {

View file

@ -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
}
}

View file

@ -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') {

View file

@ -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
View 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

View file

@ -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 })) || [],

View file

@ -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

View file

@ -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,

View file

@ -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)
}

View file

@ -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
//

View file

@ -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)

View file

@ -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

View file

@ -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++

View file

@ -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

View file

@ -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
})
}

View file

@ -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'
}

View file

@ -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,

View file

@ -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
}

View file

@ -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}`)]]

View file

@ -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') {

View 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()