mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-05 02:34:56 +02:00
Merge branch 'advplyr:master' into bookfinder-improvements
This commit is contained in:
commit
b6c789dee6
29 changed files with 558 additions and 171 deletions
|
@ -8,6 +8,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
|
|||
const OpenIDClient = require('openid-client')
|
||||
const Database = require('./Database')
|
||||
const Logger = require('./Logger')
|
||||
const e = require('express')
|
||||
|
||||
/**
|
||||
* @class Class for handling all the authentication related functionality.
|
||||
|
@ -15,6 +16,8 @@ const Logger = require('./Logger')
|
|||
class Auth {
|
||||
|
||||
constructor() {
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -187,9 +190,10 @@ class Auth {
|
|||
* @param {import('express').Response} res
|
||||
*/
|
||||
paramsToCookies(req, res) {
|
||||
if (req.query.isRest?.toLowerCase() == 'true') {
|
||||
// Set if isRest flag is set or if mobile oauth flow is used
|
||||
if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) {
|
||||
// store the isRest flag to the is_rest cookie
|
||||
res.cookie('is_rest', req.query.isRest.toLowerCase(), {
|
||||
res.cookie('is_rest', 'true', {
|
||||
maxAge: 120000, // 2 min
|
||||
httpOnly: true
|
||||
})
|
||||
|
@ -283,8 +287,27 @@ class Auth {
|
|||
// for API or mobile clients
|
||||
const oidcStrategy = passport._strategy('openid-client')
|
||||
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
|
||||
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
|
||||
Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
|
||||
|
||||
let mobile_redirect_uri = null
|
||||
|
||||
// The client wishes a different redirect_uri
|
||||
// We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect
|
||||
// where we will handle the redirect to it
|
||||
if (req.query.redirect_uri) {
|
||||
// Check if the redirect_uri is in the whitelist
|
||||
if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) ||
|
||||
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) {
|
||||
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString()
|
||||
mobile_redirect_uri = req.query.redirect_uri
|
||||
} else {
|
||||
Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`)
|
||||
return res.status(400).send('Invalid redirect_uri')
|
||||
}
|
||||
} else {
|
||||
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
|
||||
}
|
||||
|
||||
Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
|
||||
const client = oidcStrategy._client
|
||||
const sessionKey = oidcStrategy._key
|
||||
|
||||
|
@ -324,16 +347,21 @@ class Auth {
|
|||
req.session[sessionKey] = {
|
||||
...req.session[sessionKey],
|
||||
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
|
||||
mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later
|
||||
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||
}
|
||||
|
||||
// We cannot save 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(params.state, { mobile_redirect_uri: mobile_redirect_uri })
|
||||
|
||||
// Now get the URL to direct to
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
...params,
|
||||
scope: 'openid profile email',
|
||||
response_type: 'code',
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
code_challenge_method
|
||||
})
|
||||
|
||||
// params (isRest, callback) to a cookie that will be send to the client
|
||||
|
@ -347,6 +375,37 @@ class Auth {
|
|||
}
|
||||
})
|
||||
|
||||
// This will be the oauth2 callback route for mobile clients
|
||||
// It will redirect to an app-link like audiobookshelf://oauth
|
||||
router.get('/auth/openid/mobile-redirect', (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('[Auth] /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('[Auth] 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(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`)
|
||||
res.status(500).send('Internal Server Error')
|
||||
}
|
||||
})
|
||||
|
||||
// openid strategy callback route (this receives the token from the configured openid login provider)
|
||||
router.get('/auth/openid/callback', (req, res, next) => {
|
||||
const oidcStrategy = passport._strategy('openid-client')
|
||||
|
@ -403,11 +462,8 @@ class Auth {
|
|||
|
||||
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
|
||||
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
|
||||
if (req.session[sessionKey].mobile) {
|
||||
return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' }, passportCallback(req, res, next))(req, res, next)
|
||||
} else {
|
||||
return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next)
|
||||
}
|
||||
// We set it here again because the passport param can change between requests
|
||||
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
|
||||
},
|
||||
// on a successfull login: read the cookies and react like the client requested (callback or json)
|
||||
this.handleLoginSuccessBasedOnCookie.bind(this))
|
||||
|
@ -542,13 +598,13 @@ class Auth {
|
|||
// Load the user given it's username
|
||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (user.type === 'root' && !user.pash) {
|
||||
if (password) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
|
@ -557,6 +613,10 @@ class Auth {
|
|||
// approve login
|
||||
done(null, user)
|
||||
return
|
||||
} else if (!user.pash) {
|
||||
Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`)
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check password match
|
||||
|
|
|
@ -8,6 +8,7 @@ const Database = require('../Database')
|
|||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||
const { sanitizeFilename } = require('../utils/fileUtils')
|
||||
|
||||
const TaskManager = require('../managers/TaskManager')
|
||||
|
||||
|
@ -32,12 +33,9 @@ class MiscController {
|
|||
Logger.error('Invalid request, no files')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const files = Object.values(req.files)
|
||||
const title = req.body.title
|
||||
const author = req.body.author
|
||||
const series = req.body.series
|
||||
const libraryId = req.body.library
|
||||
const folderId = req.body.folder
|
||||
const { title, author, series, folder: folderId, library: libraryId } = req.body
|
||||
|
||||
const library = await Database.libraryModel.getOldById(libraryId)
|
||||
if (!library) {
|
||||
|
@ -52,43 +50,29 @@ class MiscController {
|
|||
return res.status(500).send(`Invalid post data`)
|
||||
}
|
||||
|
||||
// For setting permissions recursively
|
||||
let outputDirectory = ''
|
||||
let firstDirPath = ''
|
||||
|
||||
if (library.isPodcast) { // Podcasts only in 1 folder
|
||||
outputDirectory = Path.join(folder.fullPath, title)
|
||||
firstDirPath = outputDirectory
|
||||
} else {
|
||||
firstDirPath = Path.join(folder.fullPath, author)
|
||||
if (series && author) {
|
||||
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
||||
} else if (author) {
|
||||
outputDirectory = Path.join(folder.fullPath, author, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(folder.fullPath, title)
|
||||
}
|
||||
}
|
||||
|
||||
if (await fs.pathExists(outputDirectory)) {
|
||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||
return res.status(500).send(`Directory "${outputDirectory}" already exists`)
|
||||
}
|
||||
// Podcasts should only be one folder deep
|
||||
const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
|
||||
// `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
|
||||
// before sanitizing all the directory parts to remove illegal chars and finally prepending
|
||||
// the base folder path
|
||||
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
|
||||
const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
for (const file of files) {
|
||||
const path = Path.join(outputDirectory, sanitizeFilename(file.name))
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).then(() => {
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
return false
|
||||
})
|
||||
await file.mv(path)
|
||||
.then(() => {
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
|
@ -645,6 +629,27 @@ class MiscController {
|
|||
} else {
|
||||
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
|
||||
}
|
||||
} else if (key === 'authOpenIDMobileRedirectURIs') {
|
||||
function isValidRedirectURI(uri) {
|
||||
if (typeof uri !== 'string') return false
|
||||
const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i')
|
||||
return pattern.test(uri)
|
||||
}
|
||||
|
||||
const uris = settingsUpdate[key]
|
||||
if (!Array.isArray(uris) ||
|
||||
(uris.includes('*') && uris.length > 1) ||
|
||||
uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) {
|
||||
Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update the URIs
|
||||
if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) {
|
||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`)
|
||||
Database.serverSettings[key] = uris
|
||||
hasUpdates = true
|
||||
}
|
||||
} else {
|
||||
const updatedValueType = typeof settingsUpdate[key]
|
||||
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
|
||||
|
@ -687,8 +692,9 @@ class MiscController {
|
|||
}
|
||||
|
||||
res.json({
|
||||
updated: hasUpdates,
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = new MiscController()
|
||||
module.exports = new MiscController()
|
||||
|
|
|
@ -13,7 +13,7 @@ class ApiCacheManager {
|
|||
}
|
||||
|
||||
init(database = Database) {
|
||||
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy']
|
||||
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy', 'afterUpsert']
|
||||
hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
|
||||
}
|
||||
|
||||
|
|
|
@ -71,6 +71,7 @@ class ServerSettings {
|
|||
this.authOpenIDAutoLaunch = false
|
||||
this.authOpenIDAutoRegister = false
|
||||
this.authOpenIDMatchExistingBy = null
|
||||
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
|
@ -126,6 +127,7 @@ class ServerSettings {
|
|||
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
|
||||
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
|
||||
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
|
||||
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||
|
||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||
this.authActiveAuthMethods = ['local']
|
||||
|
@ -211,7 +213,8 @@ class ServerSettings {
|
|||
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -220,6 +223,7 @@ class ServerSettings {
|
|||
delete json.tokenSecret
|
||||
delete json.authOpenIDClientID
|
||||
delete json.authOpenIDClientSecret
|
||||
delete json.authOpenIDMobileRedirectURIs
|
||||
return json
|
||||
}
|
||||
|
||||
|
@ -254,7 +258,8 @@ class ServerSettings {
|
|||
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,27 @@ class Audible {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation"
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/2380
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/1339
|
||||
*
|
||||
* @param {string} seriesName
|
||||
* @param {string} sequence
|
||||
* @returns {string}
|
||||
*/
|
||||
cleanSeriesSequence(seriesName, sequence) {
|
||||
if (!sequence) return ''
|
||||
let updatedSequence = sequence.replace(/Book /, '').trim()
|
||||
if (updatedSequence.includes(' ')) {
|
||||
updatedSequence = updatedSequence.split(' ').shift().replace(/,$/, '')
|
||||
}
|
||||
if (sequence !== updatedSequence) {
|
||||
Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
|
||||
}
|
||||
return updatedSequence
|
||||
}
|
||||
|
||||
cleanResult(item) {
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||
|
||||
|
@ -25,13 +46,13 @@ class Audible {
|
|||
if (seriesPrimary) {
|
||||
series.push({
|
||||
series: seriesPrimary.name,
|
||||
sequence: (seriesPrimary.position || '').replace(/Book /, '') // Can be badly formatted see #1339
|
||||
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
|
||||
})
|
||||
}
|
||||
if (seriesSecondary) {
|
||||
series.push({
|
||||
series: seriesSecondary.name,
|
||||
sequence: (seriesSecondary.position || '').replace(/Book /, '')
|
||||
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -64,7 +85,7 @@ class Audible {
|
|||
}
|
||||
|
||||
asinSearch(asin, region) {
|
||||
asin = encodeURIComponent(asin);
|
||||
asin = encodeURIComponent(asin)
|
||||
var regionQuery = region ? `?region=${region}` : ''
|
||||
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
|
||||
Logger.debug(`[Audible] ASIN url: ${url}`)
|
||||
|
|
|
@ -308,6 +308,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
|||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
||||
|
||||
// Check if basename is too many bytes
|
||||
const ext = Path.extname(sanitized) // separate out file extension
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue