mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-07 08:34:10 +01:00
Merge branch 'caching' of https://github.com/mikiher/audiobookshelf into caching
This commit is contained in:
parent
107b4b83c1
commit
5aeb6ade72
3
.gitignore
vendored
3
.gitignore
vendored
@ -7,11 +7,12 @@
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
/deploy/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -16,5 +16,6 @@
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove"
|
||||
}
|
@ -258,4 +258,24 @@ Bookshelf Label
|
||||
|
||||
.no-bars .Vue-Toastification__container.top-right {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.abs-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.abs-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.abs-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
@ -104,6 +104,11 @@ export default {
|
||||
id: 'config-rss-feeds',
|
||||
title: this.$strings.HeaderRSSFeeds,
|
||||
path: '/config/rss-feeds'
|
||||
},
|
||||
{
|
||||
id: 'config-authentication',
|
||||
title: this.$strings.HeaderAuthentication,
|
||||
path: '/config/authentication'
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||
<nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
@ -7,7 +7,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
|
||||
<button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
@ -72,23 +72,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
.btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
@ -57,6 +57,7 @@ export default {
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
|
||||
}
|
||||
return this.$strings.HeaderSettings
|
||||
}
|
||||
|
229
client/pages/config/authentication.vue
Normal file
229
client/pages/config/authentication.vue
Normal file
@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderAuthentication">
|
||||
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
|
||||
<div class="flex items-center">
|
||||
<ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">Password Authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
|
||||
<div class="flex items-center">
|
||||
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">OpenID Connect Authentication</p>
|
||||
</div>
|
||||
|
||||
<transition name="slide">
|
||||
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
|
||||
<div class="w-full flex items-center mb-2">
|
||||
<div class="flex-grow">
|
||||
<ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" />
|
||||
</div>
|
||||
<div class="w-36 mx-1 mt-[1.375rem]">
|
||||
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick">
|
||||
<span class="material-icons text-base">auto_fix_high</span>
|
||||
<span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="'Button Text'" class="mb-2" />
|
||||
|
||||
<div class="flex items-center pt-1 mb-2">
|
||||
<div class="w-44">
|
||||
<ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" label="Match existing users by" :disabled="savingSettings" />
|
||||
</div>
|
||||
<p class="pl-4 text-sm text-gray-300 mt-5">Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-4 px-1">
|
||||
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
|
||||
<p id="auto-redirect-toggle" class="pl-4">Auto Launch</p>
|
||||
<p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the login page</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-4 px-1">
|
||||
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
|
||||
<p id="auto-register-toggle" class="pl-4">Auto Register</p>
|
||||
<p class="pl-4 text-sm text-gray-300">Automatically create new users after logging in</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="w-full flex items-center justify-end p-4">
|
||||
<ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, redirect, app }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!authSettings) {
|
||||
redirect('/config')
|
||||
return
|
||||
}
|
||||
return {
|
||||
authSettings
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
enableLocalAuth: false,
|
||||
enableOpenIDAuth: false,
|
||||
savingSettings: false,
|
||||
newAuthSettings: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authMethods() {
|
||||
return this.authSettings.authActiveAuthMethods || []
|
||||
},
|
||||
matchingExistingOptions() {
|
||||
return [
|
||||
{
|
||||
text: 'Do not match',
|
||||
value: null
|
||||
},
|
||||
{
|
||||
text: 'Match by email',
|
||||
value: 'email'
|
||||
},
|
||||
{
|
||||
text: 'Match by username',
|
||||
value: 'username'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
autoPopulateOIDCClick() {
|
||||
if (!this.newAuthSettings.authOpenIDIssuerURL) {
|
||||
this.$toast.error('Issuer URL required')
|
||||
return
|
||||
}
|
||||
// Remove trailing slash
|
||||
let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL
|
||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||
|
||||
// If the full config path is on the issuer url then remove it
|
||||
if (issuerUrl.endsWith('/.well-known/openid-configuration')) {
|
||||
issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '')
|
||||
this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '')
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/auth/openid/config?issuer=${issuerUrl}`)
|
||||
.then((data) => {
|
||||
if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer
|
||||
if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint
|
||||
if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint
|
||||
if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint
|
||||
if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint
|
||||
if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to receive data', error)
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
},
|
||||
validateOpenID() {
|
||||
let isValid = true
|
||||
if (!this.newAuthSettings.authOpenIDIssuerURL) {
|
||||
this.$toast.error('Issuer URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDAuthorizationURL) {
|
||||
this.$toast.error('Authorize URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDTokenURL) {
|
||||
this.$toast.error('Token URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDUserInfoURL) {
|
||||
this.$toast.error('Userinfo URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDJwksURL) {
|
||||
this.$toast.error('JWKS URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDClientID) {
|
||||
this.$toast.error('Client ID required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDClientSecret) {
|
||||
this.$toast.error('Client Secret required')
|
||||
isValid = false
|
||||
}
|
||||
return isValid
|
||||
},
|
||||
async saveSettings() {
|
||||
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
|
||||
this.$toast.error('Must have at least one authentication method enabled')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.enableOpenIDAuth && !this.validateOpenID()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.newAuthSettings.authActiveAuthMethods = []
|
||||
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
|
||||
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
|
||||
|
||||
this.savingSettings = true
|
||||
this.$axios
|
||||
.$patch('/api/auth-settings', this.newAuthSettings)
|
||||
.then((data) => {
|
||||
this.$store.commit('setServerSettings', data.serverSettings)
|
||||
this.$toast.success('Server settings updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.$toast.error('Failed to update server settings')
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingSettings = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.newAuthSettings = {
|
||||
...this.authSettings
|
||||
}
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -25,9 +25,12 @@
|
||||
</div>
|
||||
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
|
||||
<p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
<form @submit.prevent="submitForm">
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" />
|
||||
|
||||
@ -37,6 +40,14 @@
|
||||
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<div class="w-full flex py-3">
|
||||
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
|
||||
{{ openIDButtonText }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -60,7 +71,10 @@ export default {
|
||||
},
|
||||
confirmPassword: '',
|
||||
ConfigPath: '',
|
||||
MetadataPath: ''
|
||||
MetadataPath: '',
|
||||
login_local: true,
|
||||
login_openid: false,
|
||||
authFormData: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -93,6 +107,12 @@ export default {
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
openidAuthUri() {
|
||||
return `${process.env.serverUrl}/auth/openid?callback=${location.href.split('?').shift()}`
|
||||
},
|
||||
openIDButtonText() {
|
||||
return this.authFormData?.authOpenIDButtonText || 'Login with OpenId'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -162,6 +182,7 @@ export default {
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
|
||||
if (authRes?.error) {
|
||||
this.error = authRes.error
|
||||
} else if (authRes) {
|
||||
@ -196,28 +217,62 @@ export default {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get('/status')
|
||||
.then((res) => {
|
||||
this.processing = false
|
||||
this.isInit = res.isInit
|
||||
this.showInitScreen = !res.isInit
|
||||
this.$setServerLanguageCode(res.language)
|
||||
.then((data) => {
|
||||
this.isInit = data.isInit
|
||||
this.showInitScreen = !data.isInit
|
||||
this.$setServerLanguageCode(data.language)
|
||||
if (this.showInitScreen) {
|
||||
this.ConfigPath = res.ConfigPath || ''
|
||||
this.MetadataPath = res.MetadataPath || ''
|
||||
this.ConfigPath = data.ConfigPath || ''
|
||||
this.MetadataPath = data.MetadataPath || ''
|
||||
} else {
|
||||
this.authFormData = data.authFormData
|
||||
this.updateLoginVisibility(data.authMethods || [])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Status check failed', error)
|
||||
this.processing = false
|
||||
this.criticalError = 'Status check failed'
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
updateLoginVisibility(authMethods) {
|
||||
if (this.$route.query?.error) {
|
||||
this.error = this.$route.query.error
|
||||
|
||||
// Remove error query string
|
||||
const newurl = new URL(location.href)
|
||||
newurl.searchParams.delete('error')
|
||||
window.history.replaceState({ path: newurl.href }, '', newurl.href)
|
||||
}
|
||||
|
||||
if (authMethods.includes('local') || !authMethods.length) {
|
||||
this.login_local = true
|
||||
} else {
|
||||
this.login_local = false
|
||||
}
|
||||
|
||||
if (authMethods.includes('openid')) {
|
||||
// Auto redirect unless query string ?autoLaunch=0
|
||||
if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') {
|
||||
window.location.href = this.openidAuthUri
|
||||
}
|
||||
|
||||
this.login_openid = true
|
||||
} else {
|
||||
this.login_openid = false
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (localStorage.getItem('token')) {
|
||||
var userfound = await this.checkAuth()
|
||||
if (userfound) return // if valid user no need to check status
|
||||
if (this.$route.query?.setToken) {
|
||||
localStorage.setItem('token', this.$route.query.setToken)
|
||||
}
|
||||
if (localStorage.getItem('token')) {
|
||||
if (await this.checkAuth()) return // if valid user no need to check status
|
||||
}
|
||||
|
||||
this.checkStatus()
|
||||
}
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ export const getters = {
|
||||
|
||||
export const actions = {
|
||||
updateServerSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
...payload
|
||||
}
|
||||
return this.$axios.$patch('/api/settings', updatePayload).then((result) => {
|
||||
|
@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
|
4743
package-lock.json
generated
4743
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@ -15,7 +15,9 @@
|
||||
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
|
||||
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
|
||||
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
|
||||
"deploy-linux": "node deploy/linux"
|
||||
"deploy-linux": "node deploy/linux",
|
||||
"test": "mocha",
|
||||
"coverage": "nyc mocha"
|
||||
},
|
||||
"bin": "prod.js",
|
||||
"pkg": {
|
||||
@ -28,16 +30,24 @@
|
||||
"server/**/*.js"
|
||||
]
|
||||
},
|
||||
"mocha": {
|
||||
"recursive": true
|
||||
},
|
||||
"author": "advplyr",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.3",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"lru-cache": "^10.0.2",
|
||||
"lru-cache": "^10.0.3",
|
||||
"node-tone": "^1.0.1",
|
||||
"nodemailer": "^6.9.2",
|
||||
"openid-client": "^5.6.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"sequelize": "^6.32.1",
|
||||
"socket.io": "^4.5.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
@ -45,6 +55,10 @@
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.20"
|
||||
"chai": "^4.3.10",
|
||||
"mocha": "^10.2.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"nyc": "^15.1.0",
|
||||
"sinon": "^17.0.1"
|
||||
}
|
||||
}
|
||||
|
678
server/Auth.js
678
server/Auth.js
@ -1,32 +1,466 @@
|
||||
const axios = require('axios')
|
||||
const passport = require('passport')
|
||||
const bcrypt = require('./libs/bcryptjs')
|
||||
const jwt = require('./libs/jsonwebtoken')
|
||||
const requestIp = require('./libs/requestIp')
|
||||
const Logger = require('./Logger')
|
||||
const LocalStrategy = require('./libs/passportLocal')
|
||||
const JwtStrategy = require('passport-jwt').Strategy
|
||||
const ExtractJwt = require('passport-jwt').ExtractJwt
|
||||
const OpenIDClient = require('openid-client')
|
||||
const Database = require('./Database')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
/**
|
||||
* @class Class for handling all the authentication related functionality.
|
||||
*/
|
||||
class Auth {
|
||||
constructor() { }
|
||||
|
||||
cors(req, res, next) {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||
res.header('Access-Control-Allow-Headers', '*')
|
||||
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
|
||||
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||
res.header('Access-Control-Allow-Credentials', true)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200)
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inializes all passportjs strategies and other passportjs ralated initialization.
|
||||
*/
|
||||
async initPassportJs() {
|
||||
// Check if we should load the local strategy (username + password login)
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
|
||||
this.initAuthStrategyPassword()
|
||||
}
|
||||
|
||||
// Check if we should load the openid strategy
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes("openid")) {
|
||||
this.initAuthStrategyOpenID()
|
||||
}
|
||||
|
||||
// Load the JwtStrategy (always) -> for bearer token auth
|
||||
passport.use(new JwtStrategy({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
|
||||
secretOrKey: Database.serverSettings.tokenSecret
|
||||
}, this.jwtAuthCheck.bind(this)))
|
||||
|
||||
// define how to seralize a user (to be put into the session)
|
||||
passport.serializeUser(function (user, cb) {
|
||||
process.nextTick(function () {
|
||||
// only store id to session
|
||||
return cb(null, JSON.stringify({
|
||||
id: user.id,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// define how to deseralize a user (use the ID to get it from the database)
|
||||
passport.deserializeUser((function (user, cb) {
|
||||
process.nextTick((async function () {
|
||||
const parsedUserInfo = JSON.parse(user)
|
||||
// load the user by ID that is stored in the session
|
||||
const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
|
||||
return cb(null, dbUser)
|
||||
}).bind(this))
|
||||
}).bind(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Passport use LocalStrategy
|
||||
*/
|
||||
initAuthStrategyPassword() {
|
||||
passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Passport use OpenIDClient.Strategy
|
||||
*/
|
||||
initAuthStrategyOpenID() {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}).Client
|
||||
const openIdClient = new openIdIssuerClient({
|
||||
client_id: global.ServerSettings.authOpenIDClientID,
|
||||
client_secret: global.ServerSettings.authOpenIDClientSecret
|
||||
})
|
||||
passport.use('openid-client', new OpenIDClient.Strategy({
|
||||
client: openIdClient,
|
||||
params: {
|
||||
redirect_uri: '/auth/openid/callback',
|
||||
scope: 'openid profile email'
|
||||
}
|
||||
}, async (tokenset, userinfo, done) => {
|
||||
Logger.debug(`[Auth] openid callback userinfo=`, userinfo)
|
||||
|
||||
let failureMessage = 'Unauthorized'
|
||||
if (!userinfo.sub) {
|
||||
Logger.error(`[Auth] openid callback invalid userinfo, no sub`)
|
||||
return done(null, null, failureMessage)
|
||||
}
|
||||
|
||||
// First check for matching user by sub
|
||||
let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub)
|
||||
if (!user) {
|
||||
// Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy"
|
||||
if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) {
|
||||
Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`)
|
||||
user = await Database.userModel.getUserByEmail(userinfo.email)
|
||||
// Check that user is not already matched
|
||||
if (user?.authOpenIDSub) {
|
||||
Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
|
||||
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
|
||||
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
|
||||
user = null
|
||||
}
|
||||
} else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) {
|
||||
Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`)
|
||||
user = await Database.userModel.getUserByUsername(userinfo.preferred_username)
|
||||
// Check that user is not already matched
|
||||
if (user?.authOpenIDSub) {
|
||||
Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`)
|
||||
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
|
||||
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
|
||||
user = null
|
||||
}
|
||||
}
|
||||
|
||||
// If existing user was matched and isActive then save sub to user
|
||||
if (user?.isActive) {
|
||||
Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`)
|
||||
user.authOpenIDSub = userinfo.sub
|
||||
await Database.userModel.updateFromOld(user)
|
||||
} else if (user && !user.isActive) {
|
||||
Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`)
|
||||
}
|
||||
|
||||
// Optionally auto register the user
|
||||
if (!user && Database.serverSettings.authOpenIDAutoRegister) {
|
||||
Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
|
||||
user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?.isActive) {
|
||||
if (user && !user.isActive) {
|
||||
failureMessage = 'Unauthorized'
|
||||
}
|
||||
// deny login
|
||||
done(null, null, failureMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// permit login
|
||||
return done(null, user)
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unuse strategy
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
unuseAuthStrategy(name) {
|
||||
passport.unuse(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use strategy
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
useAuthStrategy(name) {
|
||||
if (name === 'openid') {
|
||||
this.initAuthStrategyOpenID()
|
||||
} else if (name === 'local') {
|
||||
this.initAuthStrategyPassword()
|
||||
} else {
|
||||
next()
|
||||
Logger.error('[Auth] Invalid auth strategy ' + name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the client's choice how the login callback should happen in temp cookies
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
paramsToCookies(req, res) {
|
||||
if (req.query.isRest?.toLowerCase() == 'true') {
|
||||
// store the isRest flag to the is_rest cookie
|
||||
res.cookie('is_rest', req.query.isRest.toLowerCase(), {
|
||||
maxAge: 120000, // 2 min
|
||||
httpOnly: true
|
||||
})
|
||||
} else {
|
||||
// no isRest-flag set -> set is_rest cookie to false
|
||||
res.cookie('is_rest', 'false', {
|
||||
maxAge: 120000, // 2 min
|
||||
httpOnly: true
|
||||
})
|
||||
|
||||
// persist state if passed in
|
||||
if (req.query.state) {
|
||||
res.cookie('auth_state', req.query.state, {
|
||||
maxAge: 120000, // 2 min
|
||||
httpOnly: true
|
||||
})
|
||||
}
|
||||
|
||||
const callback = req.query.redirect_uri || req.query.callback
|
||||
|
||||
// check if we are missing a callback parameter - we need one if isRest=false
|
||||
if (!callback) {
|
||||
res.status(400).send({
|
||||
message: 'No callback parameter'
|
||||
})
|
||||
return
|
||||
}
|
||||
// store the callback url to the auth_cb cookie
|
||||
res.cookie('auth_cb', callback, {
|
||||
maxAge: 120000, // 2 min
|
||||
httpOnly: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs the client in the right mode about a successfull login and the token
|
||||
* (clients choise is restored from cookies).
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async handleLoginSuccessBasedOnCookie(req, res) {
|
||||
// get userLogin json (information about the user, server and the session)
|
||||
const data_json = await this.getUserLoginResponsePayload(req.user)
|
||||
|
||||
if (req.cookies.is_rest === 'true') {
|
||||
// REST request - send data
|
||||
res.json(data_json)
|
||||
} else {
|
||||
// UI request -> check if we have a callback url
|
||||
// TODO: do we want to somehow limit the values for auth_cb?
|
||||
if (req.cookies.auth_cb) {
|
||||
let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
|
||||
// UI request -> redirect to auth_cb url and send the jwt token as parameter
|
||||
res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`)
|
||||
} else {
|
||||
res.status(400).send('No callback or already expired')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all (express) routes required for authentication.
|
||||
*
|
||||
* @param {import('express').Router} router
|
||||
*/
|
||||
async initAuthRoutes(router) {
|
||||
// Local strategy login route (takes username and password)
|
||||
router.post('/login', passport.authenticate('local'), async (req, res) => {
|
||||
// return the user login response json if the login was successfull
|
||||
res.json(await this.getUserLoginResponsePayload(req.user))
|
||||
})
|
||||
|
||||
// openid strategy login route (this redirects to the configured openid login provider)
|
||||
router.get('/auth/openid', (req, res, next) => {
|
||||
try {
|
||||
// helper function from openid-client
|
||||
function pick(object, ...paths) {
|
||||
const obj = {}
|
||||
for (const path of paths) {
|
||||
if (object[path] !== undefined) {
|
||||
obj[path] = object[path]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// Get the OIDC client from the strategy
|
||||
// We need to call the client manually, because the strategy does not support forwarding the code challenge
|
||||
// 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}`)
|
||||
const client = oidcStrategy._client
|
||||
const sessionKey = oidcStrategy._key
|
||||
|
||||
let code_challenge
|
||||
let code_challenge_method
|
||||
|
||||
// If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app)
|
||||
// The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow
|
||||
// and as such will not send a code challenge, we will generate then one
|
||||
if (req.query.code_challenge) {
|
||||
code_challenge = req.query.code_challenge
|
||||
code_challenge_method = req.query.code_challenge_method || 'S256'
|
||||
|
||||
if (!['S256', 'plain'].includes(code_challenge_method)) {
|
||||
return res.status(400).send('Invalid code_challenge_method')
|
||||
}
|
||||
} else {
|
||||
// If no code_challenge is provided, assume a web application flow and generate one
|
||||
const code_verifier = OpenIDClient.generators.codeVerifier()
|
||||
code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
||||
code_challenge_method = 'S256'
|
||||
|
||||
// Store the code_verifier in the session for later use in the token exchange
|
||||
req.session[sessionKey] = { ...req.session[sessionKey], code_verifier }
|
||||
}
|
||||
|
||||
const params = {
|
||||
state: OpenIDClient.generators.random(),
|
||||
// Other params by the passport strategy
|
||||
...oidcStrategy._params
|
||||
}
|
||||
|
||||
if (!params.nonce && params.response_type.includes('id_token')) {
|
||||
params.nonce = OpenIDClient.generators.random()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Now get the URL to direct to
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
...params,
|
||||
scope: 'openid profile email',
|
||||
response_type: 'code',
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
})
|
||||
|
||||
// params (isRest, callback) to a cookie that will be send to the client
|
||||
this.paramsToCookies(req, res)
|
||||
|
||||
// Redirect the user agent (browser) to the authorization URL
|
||||
res.redirect(authorizationUrl)
|
||||
} catch (error) {
|
||||
Logger.error(`[Auth] Error in /auth/openid 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')
|
||||
const sessionKey = oidcStrategy._key
|
||||
|
||||
if (!req.session[sessionKey]) {
|
||||
return res.status(400).send('No session')
|
||||
}
|
||||
|
||||
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
|
||||
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
|
||||
// Crucial for API/Mobile clients
|
||||
if (req.query.code_verifier) {
|
||||
req.session[sessionKey].code_verifier = req.query.code_verifier
|
||||
}
|
||||
|
||||
// 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' })(req, res, next)
|
||||
} else {
|
||||
return passport.authenticate('openid-client', { failureRedirect: '/login?error=Unauthorized&autoLaunch=0' })(req, res, next)
|
||||
}
|
||||
},
|
||||
// on a successfull login: read the cookies and react like the client requested (callback or json)
|
||||
this.handleLoginSuccessBasedOnCookie.bind(this))
|
||||
|
||||
/**
|
||||
* Used to auto-populate the openid URLs in config/authentication
|
||||
*/
|
||||
router.get('/auth/openid/config', async (req, res) => {
|
||||
if (!req.query.issuer) {
|
||||
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
|
||||
}
|
||||
let issuerUrl = req.query.issuer
|
||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||
|
||||
const configUrl = `${issuerUrl}/.well-known/openid-configuration`
|
||||
axios.get(configUrl).then(({ data }) => {
|
||||
res.json({
|
||||
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
|
||||
})
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
|
||||
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
|
||||
})
|
||||
})
|
||||
|
||||
// Logout route
|
||||
router.post('/logout', (req, res) => {
|
||||
// TODO: invalidate possible JWTs
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
res.sendStatus(500)
|
||||
} else {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* middleware to use in express to only allow authenticated users.
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
isAuthenticated(req, res, next) {
|
||||
// check if session cookie says that we are authenticated
|
||||
if (req.isAuthenticated()) {
|
||||
next()
|
||||
} else {
|
||||
// try JWT to authenticate
|
||||
passport.authenticate("jwt")(req, res, next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate a jwt token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string} token
|
||||
*/
|
||||
generateAccessToken(user) {
|
||||
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {Object} tokens data
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, global.ServerSettings.tokenSecret)
|
||||
}
|
||||
catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a token which is used to encrpt/protect the jwts.
|
||||
*/
|
||||
async initTokenSecret() {
|
||||
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
|
||||
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
|
||||
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
||||
} else {
|
||||
Logger.debug(`[Auth] Setting token secret - using random bytes`)
|
||||
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
}
|
||||
await Database.updateServerSettings()
|
||||
@ -35,47 +469,79 @@ class Auth {
|
||||
const users = await Database.userModel.getOldUsers()
|
||||
if (users.length) {
|
||||
for (const user of users) {
|
||||
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
||||
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
||||
user.token = await this.generateAccessToken(user)
|
||||
}
|
||||
await Database.updateBulkUsers(users)
|
||||
}
|
||||
}
|
||||
|
||||
async authMiddleware(req, res, next) {
|
||||
var token = null
|
||||
/**
|
||||
* Checks if the user in the validated jwt_payload really exists and is active.
|
||||
* @param {Object} jwt_payload
|
||||
* @param {function} done
|
||||
*/
|
||||
async jwtAuthCheck(jwt_payload, done) {
|
||||
// load user by id from the jwt token
|
||||
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
||||
|
||||
// If using a get request, the token can be passed as a query string
|
||||
if (req.method === 'GET' && req.query && req.query.token) {
|
||||
token = req.query.token
|
||||
} else {
|
||||
const authHeader = req.headers['authorization']
|
||||
token = authHeader && authHeader.split(' ')[1]
|
||||
if (!user?.isActive) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
Logger.error('Api called without a token', req.path)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
const user = await this.verifyToken(token)
|
||||
if (!user) {
|
||||
Logger.error('Verify Token User Not Found', token)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
if (!user.isActive) {
|
||||
Logger.error('Verify Token User is disabled', token, user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
req.user = user
|
||||
next()
|
||||
// approve login
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a username and password tuple is valid and the user active.
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {function} done
|
||||
*/
|
||||
async localAuthCheckUserPw(username, password, done) {
|
||||
// Load the user given it's username
|
||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
// approve login
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
// approve login
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a password with bcrypt.
|
||||
* @param {string} password
|
||||
* @returns {string} hash
|
||||
*/
|
||||
hashPass(password) {
|
||||
return new Promise((resolve) => {
|
||||
bcrypt.hash(password, 8, (err, hash) => {
|
||||
if (err) {
|
||||
Logger.error('Hash failed', err)
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(hash)
|
||||
@ -84,36 +550,11 @@ class Auth {
|
||||
})
|
||||
}
|
||||
|
||||
generateAccessToken(payload) {
|
||||
return jwt.sign(payload, Database.serverSettings.tokenSecret)
|
||||
}
|
||||
|
||||
authenticateUser(token) {
|
||||
return this.verifyToken(token)
|
||||
}
|
||||
|
||||
verifyToken(token) {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(token, Database.serverSettings.tokenSecret, async (err, payload) => {
|
||||
if (!payload || err) {
|
||||
Logger.error('JWT Verify Token Failed', err)
|
||||
return resolve(null)
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserByIdOrOldId(payload.userId)
|
||||
if (user && user.username === payload.username) {
|
||||
resolve(user)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload returned to a user after successful login
|
||||
* @param {oldUser} user
|
||||
* @returns {object}
|
||||
* Return the login info payload for a user
|
||||
*
|
||||
* @param {Object} user
|
||||
* @returns {Promise<Object>} jsonPayload
|
||||
*/
|
||||
async getUserLoginResponsePayload(user) {
|
||||
const libraryIds = await Database.libraryModel.getAllLibraryIds()
|
||||
@ -125,97 +566,6 @@ class Auth {
|
||||
Source: global.Source
|
||||
}
|
||||
}
|
||||
|
||||
async login(req, res) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
const username = (req.body.username || '').toLowerCase()
|
||||
const password = req.body.password || ''
|
||||
|
||||
const user = await Database.userModel.getUserByUsername(username)
|
||||
|
||||
if (!user?.isActive) {
|
||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||
} else {
|
||||
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
||||
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
|
||||
return res.json(userLoginResponsePayload)
|
||||
}
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
||||
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
|
||||
res.json(userLoginResponsePayload)
|
||||
} else {
|
||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
}
|
||||
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
return bcrypt.compare(password, user.pash)
|
||||
}
|
||||
|
||||
async userChangePassword(req, res) {
|
||||
var { password, newPassword } = req.body
|
||||
newPassword = newPassword || ''
|
||||
const matchingUser = await Database.userModel.getUserById(req.user.id)
|
||||
|
||||
// Only root can have an empty password
|
||||
if (matchingUser.type !== 'root' && !newPassword) {
|
||||
return res.json({
|
||||
error: 'Invalid new password - Only root can have an empty password'
|
||||
})
|
||||
}
|
||||
|
||||
const compare = await this.comparePassword(password, matchingUser)
|
||||
if (!compare) {
|
||||
return res.json({
|
||||
error: 'Invalid password'
|
||||
})
|
||||
}
|
||||
|
||||
let pw = ''
|
||||
if (newPassword) {
|
||||
pw = await this.hashPass(newPassword)
|
||||
if (!pw) {
|
||||
return res.json({
|
||||
error: 'Hash failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
matchingUser.pash = pw
|
||||
|
||||
const success = await Database.updateUser(matchingUser)
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Auth
|
@ -11,7 +11,7 @@ class Logger {
|
||||
}
|
||||
|
||||
get timestamp() {
|
||||
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
|
||||
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS')
|
||||
}
|
||||
|
||||
get levelString() {
|
||||
|
@ -5,6 +5,7 @@ const http = require('http')
|
||||
const fs = require('./libs/fsExtra')
|
||||
const fileUpload = require('./libs/expressFileupload')
|
||||
const rateLimit = require('./libs/expressRateLimit')
|
||||
const cookieParser = require("cookie-parser")
|
||||
|
||||
const { version } = require('../package.json')
|
||||
|
||||
@ -35,6 +36,11 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
const { measureMiddleware } = require('./utils/timing')
|
||||
|
||||
//Import the main Passport and Express-Session library
|
||||
const passport = require('passport')
|
||||
const expressSession = require('express-session')
|
||||
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
this.Port = PORT
|
||||
@ -82,7 +88,8 @@ class Server {
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
// ask passportjs if the current request is authenticated
|
||||
this.auth.isAuthenticated(req, res, next)
|
||||
}
|
||||
|
||||
cancelLibraryScan(libraryId) {
|
||||
@ -128,6 +135,50 @@ class Server {
|
||||
await this.init()
|
||||
|
||||
const app = express()
|
||||
|
||||
/**
|
||||
* @temporary
|
||||
* This is necessary for the ebook API endpoint in the mobile apps
|
||||
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
|
||||
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
|
||||
* @see https://ionicframework.com/docs/troubleshooting/cors
|
||||
*/
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) {
|
||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
||||
if (allowedOrigins.some(o => o === req.get('origin'))) {
|
||||
res.header('Access-Control-Allow-Origin', req.get('origin'))
|
||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||
res.header('Access-Control-Allow-Headers', '*')
|
||||
res.header('Access-Control-Allow-Credentials', true)
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// parse cookies in requests
|
||||
app.use(cookieParser())
|
||||
// enable express-session
|
||||
app.use(expressSession({
|
||||
secret: global.ServerSettings.tokenSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
// also send the cookie if were are not on https (not every use has https)
|
||||
secure: false
|
||||
},
|
||||
}))
|
||||
// init passport.js
|
||||
app.use(passport.initialize())
|
||||
// register passport in express-session
|
||||
app.use(passport.session())
|
||||
// config passport.js
|
||||
await this.auth.initPassportJs()
|
||||
|
||||
const router = express.Router()
|
||||
app.use(global.RouterBasePath, router)
|
||||
app.disable('x-powered-by')
|
||||
@ -135,14 +186,13 @@ class Server {
|
||||
this.server = http.createServer(app)
|
||||
|
||||
router.use(measureMiddleware)
|
||||
router.use(this.auth.cors)
|
||||
router.use(fileUpload({
|
||||
defCharset: 'utf8',
|
||||
defParamCharset: 'utf8',
|
||||
useTempFiles: true,
|
||||
tempFileDir: Path.join(global.MetadataPath, 'tmp')
|
||||
}))
|
||||
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||
router.use(express.urlencoded({ extended: true, limit: "5mb" }))
|
||||
router.use(express.json({ limit: "5mb" }))
|
||||
|
||||
// Static path to generated nuxt
|
||||
@ -168,6 +218,9 @@ class Server {
|
||||
this.rssFeedManager.getFeedItem(req, res)
|
||||
})
|
||||
|
||||
// Auth routes
|
||||
await this.auth.initAuthRoutes(router)
|
||||
|
||||
// Client dynamic routes
|
||||
const dyanimicRoutes = [
|
||||
'/item/:id',
|
||||
@ -191,8 +244,8 @@ class Server {
|
||||
]
|
||||
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
|
||||
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
||||
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
// router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this))
|
||||
// router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
router.post('/init', (req, res) => {
|
||||
if (Database.hasRootUser) {
|
||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||
@ -204,8 +257,12 @@ class Server {
|
||||
// status check for client to see if server has been initialized
|
||||
// server has been initialized if a root user exists
|
||||
const payload = {
|
||||
app: 'audiobookshelf',
|
||||
serverVersion: version,
|
||||
isInit: Database.hasRootUser,
|
||||
language: Database.serverSettings.language
|
||||
language: Database.serverSettings.language,
|
||||
authMethods: Database.serverSettings.authActiveAuthMethods,
|
||||
authFormData: Database.serverSettings.authFormData
|
||||
}
|
||||
if (!payload.isInit) {
|
||||
payload.ConfigPath = global.ConfigPath
|
||||
|
@ -1,6 +1,7 @@
|
||||
const SocketIO = require('socket.io')
|
||||
const Logger = require('./Logger')
|
||||
const Database = require('./Database')
|
||||
const Auth = require('./Auth')
|
||||
|
||||
class SocketAuthority {
|
||||
constructor() {
|
||||
@ -81,6 +82,7 @@ class SocketAuthority {
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
})
|
||||
|
||||
this.io.on('connection', (socket) => {
|
||||
this.clients[socket.id] = {
|
||||
id: socket.id,
|
||||
@ -144,14 +146,31 @@ 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
|
||||
/**
|
||||
* When setting up a socket connection the user needs to be associated with a socket id
|
||||
* for this the client will send a 'auth' event that includes the users API token
|
||||
*
|
||||
* @param {SocketIO.Socket} socket
|
||||
* @param {string} token JWT
|
||||
*/
|
||||
async authenticateSocket(socket, token) {
|
||||
const user = await this.Server.auth.authenticateUser(token)
|
||||
if (!user) {
|
||||
// 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)
|
||||
|
||||
if (!token_data?.userId) {
|
||||
// Token invalid
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('invalid_token')
|
||||
}
|
||||
// get the user via the id from the decoded jwt.
|
||||
const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
|
||||
if (!user) {
|
||||
// user not found
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('invalid_token')
|
||||
}
|
||||
|
||||
const client = this.clients[socket.id]
|
||||
if (!client) {
|
||||
Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)
|
||||
|
@ -119,8 +119,9 @@ class MiscController {
|
||||
/**
|
||||
* PATCH: /api/settings
|
||||
* Update server settings
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async updateServerSettings(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
@ -128,7 +129,7 @@ class MiscController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
if (!isObject(settingsUpdate)) {
|
||||
return res.status(400).send('Invalid settings update object')
|
||||
}
|
||||
|
||||
@ -248,8 +249,8 @@ class MiscController {
|
||||
* POST: /api/authorize
|
||||
* Used to authorize an API token
|
||||
*
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async authorize(req, res) {
|
||||
if (!req.user) {
|
||||
@ -555,10 +556,10 @@ class MiscController {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
this.watcher.onFileAdded(libraryId, path)
|
||||
break;
|
||||
break
|
||||
case 'unlink':
|
||||
this.watcher.onFileRemoved(libraryId, path)
|
||||
break;
|
||||
break
|
||||
case 'rename':
|
||||
const oldPath = req.body.oldPath
|
||||
if (!oldPath) {
|
||||
@ -566,7 +567,7 @@ class MiscController {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
this.watcher.onFileRename(libraryId, oldPath, path)
|
||||
break;
|
||||
break
|
||||
default:
|
||||
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
|
||||
return res.sendStatus(400)
|
||||
@ -589,5 +590,105 @@ class MiscController {
|
||||
res.status(400).send(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: api/auth-settings (admin only)
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
getAuthSettings(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
return res.json(Database.serverSettings.authenticationSettings)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: api/auth-settings
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async updateAuthSettings(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to update auth settings`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const settingsUpdate = req.body
|
||||
if (!isObject(settingsUpdate)) {
|
||||
return res.status(400).send('Invalid auth settings update object')
|
||||
}
|
||||
|
||||
let hasUpdates = false
|
||||
|
||||
const currentAuthenticationSettings = Database.serverSettings.authenticationSettings
|
||||
const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods]
|
||||
|
||||
// TODO: Better validation of auth settings once auth settings are separated from server settings
|
||||
for (const key in currentAuthenticationSettings) {
|
||||
if (settingsUpdate[key] === undefined) continue
|
||||
|
||||
if (key === 'authActiveAuthMethods') {
|
||||
let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
|
||||
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
|
||||
updatedAuthMethods.sort()
|
||||
currentAuthenticationSettings[key].sort()
|
||||
if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) {
|
||||
Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`)
|
||||
Database.serverSettings[key] = updatedAuthMethods
|
||||
hasUpdates = true
|
||||
}
|
||||
} else {
|
||||
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
|
||||
}
|
||||
} else {
|
||||
const updatedValueType = typeof settingsUpdate[key]
|
||||
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
|
||||
if (updatedValueType !== 'boolean') {
|
||||
Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`)
|
||||
continue
|
||||
}
|
||||
} else if (settingsUpdate[key] !== null && updatedValueType !== 'string') {
|
||||
Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`)
|
||||
continue
|
||||
}
|
||||
let updatedValue = settingsUpdate[key]
|
||||
if (updatedValue === '') updatedValue = null
|
||||
let currentValue = currentAuthenticationSettings[key]
|
||||
if (currentValue === '') currentValue = null
|
||||
|
||||
if (updatedValue !== currentValue) {
|
||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
||||
Database.serverSettings[key] = updatedValue
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
await Database.updateServerSettings()
|
||||
|
||||
// Use/unuse auth methods
|
||||
Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {
|
||||
if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
|
||||
// Auth method has been removed
|
||||
Logger.info(`[MiscController] Disabling active auth method "${authMethod}"`)
|
||||
this.auth.unuseAuthStrategy(authMethod)
|
||||
} else if (!originalAuthMethods.includes(authMethod) && Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
|
||||
// Auth method has been added
|
||||
Logger.info(`[MiscController] Enabling active auth method "${authMethod}"`)
|
||||
this.auth.useAuthStrategy(authMethod)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = new MiscController()
|
@ -6,7 +6,7 @@ class SessionController {
|
||||
constructor() { }
|
||||
|
||||
async findOne(req, res) {
|
||||
return res.json(req.session)
|
||||
return res.json(req.playbackSession)
|
||||
}
|
||||
|
||||
async getAllWithUserData(req, res) {
|
||||
@ -63,32 +63,32 @@ class SessionController {
|
||||
}
|
||||
|
||||
async getOpenSession(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
|
||||
const sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
|
||||
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
|
||||
res.json(sessionForClient)
|
||||
}
|
||||
|
||||
// POST: api/session/:id/sync
|
||||
sync(req, res) {
|
||||
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res)
|
||||
this.playbackSessionManager.syncSessionRequest(req.user, req.playbackSession, req.body, res)
|
||||
}
|
||||
|
||||
// POST: api/session/:id/close
|
||||
close(req, res) {
|
||||
let syncData = req.body
|
||||
if (syncData && !Object.keys(syncData).length) syncData = null
|
||||
this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res)
|
||||
this.playbackSessionManager.closeSessionRequest(req.user, req.playbackSession, syncData, res)
|
||||
}
|
||||
|
||||
// DELETE: api/session/:id
|
||||
async delete(req, res) {
|
||||
// if session is open then remove it
|
||||
const openSession = this.playbackSessionManager.getSession(req.session.id)
|
||||
const openSession = this.playbackSessionManager.getSession(req.playbackSession.id)
|
||||
if (openSession) {
|
||||
await this.playbackSessionManager.removeSession(req.session.id)
|
||||
await this.playbackSessionManager.removeSession(req.playbackSession.id)
|
||||
}
|
||||
|
||||
await Database.removePlaybackSession(req.session.id)
|
||||
await Database.removePlaybackSession(req.playbackSession.id)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@ -111,7 +111,7 @@ class SessionController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
req.session = playbackSession
|
||||
req.playbackSession = playbackSession
|
||||
next()
|
||||
}
|
||||
|
||||
@ -130,7 +130,7 @@ class SessionController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.session = playbackSession
|
||||
req.playbackSession = playbackSession
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ class UserController {
|
||||
account.id = uuidv4()
|
||||
account.pash = await this.auth.hashPass(account.password)
|
||||
delete account.password
|
||||
account.token = await this.auth.generateAccessToken({ userId: account.id, username })
|
||||
account.token = await this.auth.generateAccessToken(account)
|
||||
account.createdAt = Date.now()
|
||||
const newUser = new User(account)
|
||||
|
||||
@ -150,7 +150,7 @@ class UserController {
|
||||
|
||||
if (user.update(account)) {
|
||||
if (shouldUpdateToken) {
|
||||
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
|
||||
user.token = await this.auth.generateAccessToken(user)
|
||||
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
||||
}
|
||||
await Database.updateUser(user)
|
||||
|
@ -31,52 +31,11 @@ class BookFinder {
|
||||
return book
|
||||
}
|
||||
|
||||
stripSubtitle(title) {
|
||||
if (title.includes(':')) {
|
||||
return title.split(':')[0].trim()
|
||||
} else if (title.includes(' - ')) {
|
||||
return title.split(' - ')[0].trim()
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
replaceAccentedChars(str) {
|
||||
try {
|
||||
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
} catch (error) {
|
||||
Logger.error('[BookFinder] str normalize error', error)
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
cleanTitleForCompares(title) {
|
||||
if (!title) return ''
|
||||
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
|
||||
let stripped = this.stripSubtitle(title)
|
||||
|
||||
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
|
||||
let cleaned = stripped.replace(/ *\([^)]*\) */g, "")
|
||||
|
||||
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
|
||||
cleaned = cleaned.replace(/'/g, '')
|
||||
return this.replaceAccentedChars(cleaned).toLowerCase()
|
||||
}
|
||||
|
||||
cleanAuthorForCompares(author) {
|
||||
if (!author) return ''
|
||||
let cleanAuthor = this.replaceAccentedChars(author).toLowerCase()
|
||||
// separate initials
|
||||
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
|
||||
// remove middle initials
|
||||
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
|
||||
return cleanAuthor
|
||||
}
|
||||
|
||||
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var searchTitle = this.cleanTitleForCompares(title)
|
||||
var searchAuthor = this.cleanAuthorForCompares(author)
|
||||
var searchTitle = cleanTitleForCompares(title)
|
||||
var searchAuthor = cleanAuthorForCompares(author)
|
||||
return books.map(b => {
|
||||
b.cleanedTitle = this.cleanTitleForCompares(b.title)
|
||||
b.cleanedTitle = cleanTitleForCompares(b.title)
|
||||
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
|
||||
|
||||
// Total length of search (title or both title & author)
|
||||
@ -87,7 +46,7 @@ class BookFinder {
|
||||
b.authorDistance = author.length
|
||||
} else {
|
||||
b.totalPossibleDistance += b.author.length
|
||||
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
|
||||
b.cleanedAuthor = cleanAuthorForCompares(b.author)
|
||||
|
||||
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
|
||||
var authorDistance = levenshteinDistance(b.author || '', author)
|
||||
@ -190,20 +149,17 @@ class BookFinder {
|
||||
|
||||
static TitleCandidates = class {
|
||||
|
||||
constructor(bookFinder, cleanAuthor) {
|
||||
this.bookFinder = bookFinder
|
||||
constructor(cleanAuthor) {
|
||||
this.candidates = new Set()
|
||||
this.cleanAuthor = cleanAuthor
|
||||
this.priorities = {}
|
||||
this.positions = {}
|
||||
this.currentPosition = 0
|
||||
}
|
||||
|
||||
add(title, position = 0) {
|
||||
add(title) {
|
||||
// if title contains the author, remove it
|
||||
if (this.cleanAuthor) {
|
||||
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
|
||||
title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim()
|
||||
}
|
||||
title = this.#removeAuthorFromTitle(title)
|
||||
|
||||
const titleTransformers = [
|
||||
[/([,:;_]| by ).*/g, ''], // Remove subtitle
|
||||
@ -215,11 +171,11 @@ class BookFinder {
|
||||
]
|
||||
|
||||
// Main variant
|
||||
const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim()
|
||||
const cleanTitle = cleanTitleForCompares(title).trim()
|
||||
if (!cleanTitle) return
|
||||
this.candidates.add(cleanTitle)
|
||||
this.priorities[cleanTitle] = 0
|
||||
this.positions[cleanTitle] = position
|
||||
this.positions[cleanTitle] = this.currentPosition
|
||||
|
||||
let candidate = cleanTitle
|
||||
|
||||
@ -230,10 +186,11 @@ class BookFinder {
|
||||
if (candidate) {
|
||||
this.candidates.add(candidate)
|
||||
this.priorities[candidate] = 0
|
||||
this.positions[candidate] = position
|
||||
this.positions[candidate] = this.currentPosition
|
||||
}
|
||||
this.priorities[cleanTitle] = 1
|
||||
}
|
||||
this.currentPosition++
|
||||
}
|
||||
|
||||
get size() {
|
||||
@ -243,23 +200,16 @@ class BookFinder {
|
||||
getCandidates() {
|
||||
var candidates = [...this.candidates]
|
||||
candidates.sort((a, b) => {
|
||||
// Candidates that include the author are likely low quality
|
||||
const includesAuthorDiff = !b.includes(this.cleanAuthor) - !a.includes(this.cleanAuthor)
|
||||
if (includesAuthorDiff) return includesAuthorDiff
|
||||
// Candidates that include only digits are also likely low quality
|
||||
const onlyDigits = /^\d+$/
|
||||
const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a)
|
||||
const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b)
|
||||
if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff
|
||||
// transformed candidates receive higher priority
|
||||
const priorityDiff = this.priorities[a] - this.priorities[b]
|
||||
if (priorityDiff) return priorityDiff
|
||||
// if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles)
|
||||
const positionDiff = this.positions[a] - this.positions[b]
|
||||
if (positionDiff) return positionDiff
|
||||
// Start with longer candidaets, as they are likely more specific
|
||||
const lengthDiff = b.length - a.length
|
||||
if (lengthDiff) return lengthDiff
|
||||
return b.localeCompare(a)
|
||||
return positionDiff // candidates with same priority always have different positions
|
||||
})
|
||||
Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`)
|
||||
Logger.debug(candidates)
|
||||
@ -269,21 +219,32 @@ class BookFinder {
|
||||
delete(title) {
|
||||
return this.candidates.delete(title)
|
||||
}
|
||||
|
||||
#removeAuthorFromTitle(title) {
|
||||
if (!this.cleanAuthor) return title
|
||||
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
|
||||
const authorCleanedTitle = cleanAuthorForCompares(title)
|
||||
const authorCleanedTitleWithoutAuthor = authorCleanedTitle.replace(authorRe, '')
|
||||
if (authorCleanedTitleWithoutAuthor !== authorCleanedTitle) {
|
||||
return authorCleanedTitleWithoutAuthor.trim()
|
||||
}
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
static AuthorCandidates = class {
|
||||
constructor(bookFinder, cleanAuthor) {
|
||||
this.bookFinder = bookFinder
|
||||
constructor(cleanAuthor, audnexus) {
|
||||
this.audnexus = audnexus
|
||||
this.candidates = new Set()
|
||||
this.cleanAuthor = cleanAuthor
|
||||
if (cleanAuthor) this.candidates.add(cleanAuthor)
|
||||
}
|
||||
|
||||
validateAuthor(name, region = '', maxLevenshtein = 2) {
|
||||
return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => {
|
||||
return this.audnexus.authorASINsRequest(name, region).then((asins) => {
|
||||
for (const [i, asin] of asins.entries()) {
|
||||
if (i > 10) break
|
||||
let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name)
|
||||
let cleanName = cleanAuthorForCompares(asin.name)
|
||||
if (!cleanName) continue
|
||||
if (cleanName.includes(name)) return name
|
||||
if (name.includes(cleanName)) return cleanName
|
||||
@ -294,7 +255,7 @@ class BookFinder {
|
||||
}
|
||||
|
||||
add(author) {
|
||||
const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim()
|
||||
const cleanAuthor = cleanAuthorForCompares(author).trim()
|
||||
if (!cleanAuthor) return
|
||||
this.candidates.add(cleanAuthor)
|
||||
}
|
||||
@ -362,10 +323,10 @@ class BookFinder {
|
||||
title = title.trim().toLowerCase()
|
||||
author = author?.trim().toLowerCase() || ''
|
||||
|
||||
const cleanAuthor = this.cleanAuthorForCompares(author)
|
||||
const cleanAuthor = cleanAuthorForCompares(author)
|
||||
|
||||
// Now run up to maxFuzzySearches fuzzy searches
|
||||
let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor)
|
||||
let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)
|
||||
|
||||
// Remove underscores and parentheses with their contents, and replace with a separator
|
||||
const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ")
|
||||
@ -375,9 +336,9 @@ class BookFinder {
|
||||
authorCandidates.add(titlePart)
|
||||
authorCandidates = await authorCandidates.getCandidates()
|
||||
for (const authorCandidate of authorCandidates) {
|
||||
let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate)
|
||||
for (const [position, titlePart] of titleParts.entries())
|
||||
titleCandidates.add(titlePart, position)
|
||||
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
|
||||
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
|
||||
@ -457,3 +418,52 @@ class BookFinder {
|
||||
}
|
||||
}
|
||||
module.exports = new BookFinder()
|
||||
|
||||
function stripSubtitle(title) {
|
||||
if (title.includes(':')) {
|
||||
return title.split(':')[0].trim()
|
||||
} else if (title.includes(' - ')) {
|
||||
return title.split(' - ')[0].trim()
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
function replaceAccentedChars(str) {
|
||||
try {
|
||||
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
} catch (error) {
|
||||
Logger.error('[BookFinder] str normalize error', error)
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
function cleanTitleForCompares(title) {
|
||||
if (!title) return ''
|
||||
title = stripRedundantSpaces(title)
|
||||
|
||||
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
|
||||
let stripped = stripSubtitle(title)
|
||||
|
||||
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
|
||||
let cleaned = stripped.replace(/ *\([^)]*\) */g, "")
|
||||
|
||||
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
|
||||
cleaned = cleaned.replace(/'/g, '')
|
||||
return replaceAccentedChars(cleaned).toLowerCase()
|
||||
}
|
||||
|
||||
function cleanAuthorForCompares(author) {
|
||||
if (!author) return ''
|
||||
author = stripRedundantSpaces(author)
|
||||
|
||||
let cleanAuthor = replaceAccentedChars(author).toLowerCase()
|
||||
// separate initials
|
||||
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
|
||||
// remove middle initials
|
||||
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
|
||||
return cleanAuthor
|
||||
}
|
||||
|
||||
function stripRedundantSpaces(str) {
|
||||
return str.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
20
server/libs/passportLocal/LICENSE
Normal file
20
server/libs/passportLocal/LICENSE
Normal file
@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2014 Jared Hanson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
20
server/libs/passportLocal/index.js
Normal file
20
server/libs/passportLocal/index.js
Normal file
@ -0,0 +1,20 @@
|
||||
//
|
||||
// modified for audiobookshelf
|
||||
// Source: https://github.com/jaredhanson/passport-local
|
||||
//
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
var Strategy = require('./strategy');
|
||||
|
||||
|
||||
/**
|
||||
* Expose `Strategy` directly from package.
|
||||
*/
|
||||
exports = module.exports = Strategy;
|
||||
|
||||
/**
|
||||
* Export constructors.
|
||||
*/
|
||||
exports.Strategy = Strategy;
|
119
server/libs/passportLocal/strategy.js
Normal file
119
server/libs/passportLocal/strategy.js
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
const passport = require('passport-strategy')
|
||||
const util = require('util')
|
||||
|
||||
|
||||
function lookup(obj, field) {
|
||||
if (!obj) { return null; }
|
||||
var chain = field.split(']').join('').split('[');
|
||||
for (var i = 0, len = chain.length; i < len; i++) {
|
||||
var prop = obj[chain[i]];
|
||||
if (typeof (prop) === 'undefined') { return null; }
|
||||
if (typeof (prop) !== 'object') { return prop; }
|
||||
obj = prop;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Strategy` constructor.
|
||||
*
|
||||
* The local authentication strategy authenticates requests based on the
|
||||
* credentials submitted through an HTML-based login form.
|
||||
*
|
||||
* Applications must supply a `verify` callback which accepts `username` and
|
||||
* `password` credentials, and then calls the `done` callback supplying a
|
||||
* `user`, which should be set to `false` if the credentials are not valid.
|
||||
* If an exception occured, `err` should be set.
|
||||
*
|
||||
* Optionally, `options` can be used to change the fields in which the
|
||||
* credentials are found.
|
||||
*
|
||||
* Options:
|
||||
* - `usernameField` field name where the username is found, defaults to _username_
|
||||
* - `passwordField` field name where the password is found, defaults to _password_
|
||||
* - `passReqToCallback` when `true`, `req` is the first argument to the verify callback (default: `false`)
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* passport.use(new LocalStrategy(
|
||||
* function(username, password, done) {
|
||||
* User.findOne({ username: username, password: password }, function (err, user) {
|
||||
* done(err, user);
|
||||
* });
|
||||
* }
|
||||
* ));
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Function} verify
|
||||
* @api public
|
||||
*/
|
||||
function Strategy(options, verify) {
|
||||
if (typeof options == 'function') {
|
||||
verify = options;
|
||||
options = {};
|
||||
}
|
||||
if (!verify) { throw new TypeError('LocalStrategy requires a verify callback'); }
|
||||
|
||||
this._usernameField = options.usernameField || 'username';
|
||||
this._passwordField = options.passwordField || 'password';
|
||||
|
||||
passport.Strategy.call(this);
|
||||
this.name = 'local';
|
||||
this._verify = verify;
|
||||
this._passReqToCallback = options.passReqToCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inherit from `passport.Strategy`.
|
||||
*/
|
||||
util.inherits(Strategy, passport.Strategy);
|
||||
|
||||
/**
|
||||
* Authenticate request based on the contents of a form submission.
|
||||
*
|
||||
* @param {Object} req
|
||||
* @api protected
|
||||
*/
|
||||
Strategy.prototype.authenticate = function (req, options) {
|
||||
options = options || {};
|
||||
var username = lookup(req.body, this._usernameField)
|
||||
if (username === null) {
|
||||
lookup(req.query, this._usernameField);
|
||||
}
|
||||
|
||||
var password = lookup(req.body, this._passwordField)
|
||||
if (password === null) {
|
||||
password = lookup(req.query, this._passwordField);
|
||||
}
|
||||
|
||||
if (username === null || password === null) {
|
||||
return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
function verified(err, user, info) {
|
||||
if (err) { return self.error(err); }
|
||||
if (!user) { return self.fail(info); }
|
||||
self.success(user, info);
|
||||
}
|
||||
|
||||
try {
|
||||
if (self._passReqToCallback) {
|
||||
this._verify(req, username, password, verified);
|
||||
} else {
|
||||
this._verify(username, password, verified);
|
||||
}
|
||||
} catch (ex) {
|
||||
return self.error(ex);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Expose `Strategy`.
|
||||
*/
|
||||
module.exports = Strategy;
|
@ -1,7 +1,9 @@
|
||||
const uuidv4 = require("uuid").v4
|
||||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const sequelize = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const oldUser = require('../objects/user/User')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const { DataTypes, Model } = sequelize
|
||||
|
||||
class User extends Model {
|
||||
constructor(values, options) {
|
||||
@ -46,6 +48,12 @@ class User extends Model {
|
||||
return users.map(u => this.getOldUser(u))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old user model from new
|
||||
*
|
||||
* @param {Object} userExpanded
|
||||
* @returns {oldUser}
|
||||
*/
|
||||
static getOldUser(userExpanded) {
|
||||
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
|
||||
|
||||
@ -72,15 +80,27 @@ class User extends Model {
|
||||
createdAt: userExpanded.createdAt.valueOf(),
|
||||
permissions,
|
||||
librariesAccessible,
|
||||
itemTagsSelected
|
||||
itemTagsSelected,
|
||||
authOpenIDSub: userExpanded.extraData?.authOpenIDSub || null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {oldUser} oldUser
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static createFromOld(oldUser) {
|
||||
const user = this.getFromOld(oldUser)
|
||||
return this.create(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update User from old user model
|
||||
*
|
||||
* @param {oldUser} oldUser
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static updateFromOld(oldUser) {
|
||||
const user = this.getFromOld(oldUser)
|
||||
return this.update(user, {
|
||||
@ -93,7 +113,21 @@ class User extends Model {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get new User model from old
|
||||
*
|
||||
* @param {oldUser} oldUser
|
||||
* @returns {Object}
|
||||
*/
|
||||
static getFromOld(oldUser) {
|
||||
const extraData = {
|
||||
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
|
||||
oldUserId: oldUser.oldUserId
|
||||
}
|
||||
if (oldUser.authOpenIDSub) {
|
||||
extraData.authOpenIDSub = oldUser.authOpenIDSub
|
||||
}
|
||||
|
||||
return {
|
||||
id: oldUser.id,
|
||||
username: oldUser.username,
|
||||
@ -103,10 +137,7 @@ class User extends Model {
|
||||
token: oldUser.token || null,
|
||||
isActive: !!oldUser.isActive,
|
||||
lastSeen: oldUser.lastSeen || null,
|
||||
extraData: {
|
||||
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
|
||||
oldUserId: oldUser.oldUserId
|
||||
},
|
||||
extraData,
|
||||
createdAt: oldUser.createdAt || Date.now(),
|
||||
permissions: {
|
||||
...oldUser.permissions,
|
||||
@ -130,12 +161,12 @@ class User extends Model {
|
||||
* @param {string} username
|
||||
* @param {string} pash
|
||||
* @param {Auth} auth
|
||||
* @returns {oldUser}
|
||||
* @returns {Promise<oldUser>}
|
||||
*/
|
||||
static async createRootUser(username, pash, auth) {
|
||||
const userId = uuidv4()
|
||||
|
||||
const token = await auth.generateAccessToken({ userId, username })
|
||||
const token = await auth.generateAccessToken({ id: userId, username })
|
||||
|
||||
const newRoot = new oldUser({
|
||||
id: userId,
|
||||
@ -150,6 +181,38 @@ class User extends Model {
|
||||
return newRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user from openid userinfo
|
||||
* @param {Object} userinfo
|
||||
* @param {Auth} auth
|
||||
* @returns {Promise<oldUser>}
|
||||
*/
|
||||
static async createUserFromOpenIdUserInfo(userinfo, auth) {
|
||||
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 newUser = new oldUser({
|
||||
id: userId,
|
||||
type: 'user',
|
||||
username,
|
||||
email,
|
||||
pash: null,
|
||||
token,
|
||||
isActive: true,
|
||||
authOpenIDSub: userinfo.sub,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
if (await this.createFromOld(newUser)) {
|
||||
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
|
||||
return newUser
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by id or by the old database id
|
||||
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
|
||||
@ -160,13 +223,13 @@ class User extends Model {
|
||||
if (!userId) return null
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
[sequelize.Op.or]: [
|
||||
{
|
||||
id: userId
|
||||
},
|
||||
{
|
||||
extraData: {
|
||||
[Op.substring]: userId
|
||||
[sequelize.Op.substring]: userId
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -187,7 +250,26 @@ class User extends Model {
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
username: {
|
||||
[Op.like]: username
|
||||
[sequelize.Op.like]: username
|
||||
}
|
||||
},
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by email case insensitive
|
||||
* @param {string} username
|
||||
* @returns {Promise<oldUser|null>} returns null if not found
|
||||
*/
|
||||
static async getUserByEmail(email) {
|
||||
if (!email) return null
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
email: {
|
||||
[sequelize.Op.like]: email
|
||||
}
|
||||
},
|
||||
include: this.sequelize.models.mediaProgress
|
||||
@ -210,6 +292,21 @@ class User extends Model {
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by openid sub
|
||||
* @param {string} sub
|
||||
* @returns {Promise<oldUser|null>} returns null if not found
|
||||
*/
|
||||
static async getUserByOpenIDSub(sub) {
|
||||
if (!sub) return null
|
||||
const user = await this.findOne({
|
||||
where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub),
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of user id and username
|
||||
* @returns {object[]} { id, username }
|
||||
|
@ -54,6 +54,24 @@ class ServerSettings {
|
||||
this.version = packageJson.version
|
||||
this.buildNumber = packageJson.buildNumber
|
||||
|
||||
// Auth settings
|
||||
// Active auth methodes
|
||||
this.authActiveAuthMethods = ['local']
|
||||
|
||||
// openid settings
|
||||
this.authOpenIDIssuerURL = null
|
||||
this.authOpenIDAuthorizationURL = null
|
||||
this.authOpenIDTokenURL = null
|
||||
this.authOpenIDUserInfoURL = null
|
||||
this.authOpenIDJwksURL = null
|
||||
this.authOpenIDLogoutURL = null
|
||||
this.authOpenIDClientID = null
|
||||
this.authOpenIDClientSecret = null
|
||||
this.authOpenIDButtonText = 'Login with OpenId'
|
||||
this.authOpenIDAutoLaunch = false
|
||||
this.authOpenIDAutoRegister = false
|
||||
this.authOpenIDMatchExistingBy = null
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
}
|
||||
@ -94,6 +112,36 @@ class ServerSettings {
|
||||
this.version = settings.version || null
|
||||
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
|
||||
|
||||
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
|
||||
|
||||
this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null
|
||||
this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || null
|
||||
this.authOpenIDTokenURL = settings.authOpenIDTokenURL || null
|
||||
this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || null
|
||||
this.authOpenIDJwksURL = settings.authOpenIDJwksURL || null
|
||||
this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || null
|
||||
this.authOpenIDClientID = settings.authOpenIDClientID || null
|
||||
this.authOpenIDClientSecret = settings.authOpenIDClientSecret || null
|
||||
this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId'
|
||||
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
|
||||
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
|
||||
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
|
||||
|
||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||
this.authActiveAuthMethods = ['local']
|
||||
}
|
||||
|
||||
// remove uninitialized methods
|
||||
// OpenID
|
||||
if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) {
|
||||
this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1)
|
||||
}
|
||||
|
||||
// fallback to local
|
||||
if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) {
|
||||
this.authActiveAuthMethods = ['local']
|
||||
}
|
||||
|
||||
// Migrations
|
||||
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithBook
|
||||
@ -150,23 +198,96 @@ class ServerSettings {
|
||||
language: this.language,
|
||||
logLevel: this.logLevel,
|
||||
version: this.version,
|
||||
buildNumber: this.buildNumber
|
||||
buildNumber: this.buildNumber,
|
||||
authActiveAuthMethods: this.authActiveAuthMethods,
|
||||
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
|
||||
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
|
||||
authOpenIDTokenURL: this.authOpenIDTokenURL,
|
||||
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
|
||||
authOpenIDJwksURL: this.authOpenIDJwksURL,
|
||||
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
|
||||
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
|
||||
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
|
||||
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForBrowser() {
|
||||
const json = this.toJSON()
|
||||
delete json.tokenSecret
|
||||
delete json.authOpenIDClientID
|
||||
delete json.authOpenIDClientSecret
|
||||
return json
|
||||
}
|
||||
|
||||
get supportedAuthMethods() {
|
||||
return ['local', 'openid']
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth settings required for openid to be valid
|
||||
*/
|
||||
get isOpenIDAuthSettingsValid() {
|
||||
return this.authOpenIDIssuerURL &&
|
||||
this.authOpenIDAuthorizationURL &&
|
||||
this.authOpenIDTokenURL &&
|
||||
this.authOpenIDUserInfoURL &&
|
||||
this.authOpenIDJwksURL &&
|
||||
this.authOpenIDClientID &&
|
||||
this.authOpenIDClientSecret
|
||||
}
|
||||
|
||||
get authenticationSettings() {
|
||||
return {
|
||||
authActiveAuthMethods: this.authActiveAuthMethods,
|
||||
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
|
||||
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
|
||||
authOpenIDTokenURL: this.authOpenIDTokenURL,
|
||||
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
|
||||
authOpenIDJwksURL: this.authOpenIDJwksURL,
|
||||
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
|
||||
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
|
||||
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
|
||||
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
||||
}
|
||||
}
|
||||
|
||||
get authFormData() {
|
||||
const clientFormData = {}
|
||||
if (this.authActiveAuthMethods.includes('openid')) {
|
||||
clientFormData.authOpenIDButtonText = this.authOpenIDButtonText
|
||||
clientFormData.authOpenIDAutoLaunch = this.authOpenIDAutoLaunch
|
||||
}
|
||||
return clientFormData
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server settings
|
||||
*
|
||||
* @param {Object} payload
|
||||
* @returns {boolean} true if updates were made
|
||||
*/
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
let hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (key === 'sortingPrefixes' && payload[key] && payload[key].length) {
|
||||
var prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase())
|
||||
if (prefixesCleaned.join(',') !== this[key].join(',')) {
|
||||
this[key] = [...prefixesCleaned]
|
||||
if (key === 'sortingPrefixes') {
|
||||
// Sorting prefixes are updated with the /api/sorting-prefixes endpoint
|
||||
continue
|
||||
} else if (key === 'authActiveAuthMethods') {
|
||||
if (!payload[key]?.length) {
|
||||
Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])
|
||||
continue
|
||||
}
|
||||
this.authActiveAuthMethods.sort()
|
||||
payload[key].sort()
|
||||
if (payload[key].join() !== this.authActiveAuthMethods.join()) {
|
||||
this.authActiveAuthMethods = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== payload[key]) {
|
||||
|
@ -24,6 +24,8 @@ class User {
|
||||
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
|
||||
this.itemTagsSelected = [] // Empty if ALL item tags accessible
|
||||
|
||||
this.authOpenIDSub = null
|
||||
|
||||
if (user) {
|
||||
this.construct(user)
|
||||
}
|
||||
@ -66,7 +68,7 @@ class User {
|
||||
getDefaultUserPermissions() {
|
||||
return {
|
||||
download: true,
|
||||
update: true,
|
||||
update: this.type === 'root' || this.type === 'admin',
|
||||
delete: this.type === 'root',
|
||||
upload: this.type === 'root' || this.type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
@ -93,7 +95,8 @@ class User {
|
||||
createdAt: this.createdAt,
|
||||
permissions: this.permissions,
|
||||
librariesAccessible: [...this.librariesAccessible],
|
||||
itemTagsSelected: [...this.itemTagsSelected]
|
||||
itemTagsSelected: [...this.itemTagsSelected],
|
||||
authOpenIDSub: this.authOpenIDSub
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,6 +189,8 @@ class User {
|
||||
|
||||
this.librariesAccessible = [...(user.librariesAccessible || [])]
|
||||
this.itemTagsSelected = [...(user.itemTagsSelected || [])]
|
||||
|
||||
this.authOpenIDSub = user.authOpenIDSub || null
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
|
@ -36,6 +36,7 @@ const { measureMiddleware } = require('../utils/timing')
|
||||
|
||||
class ApiRouter {
|
||||
constructor(Server) {
|
||||
/** @type {import('../Auth')} */
|
||||
this.auth = Server.auth
|
||||
this.playbackSessionManager = Server.playbackSessionManager
|
||||
this.abMergeManager = Server.abMergeManager
|
||||
@ -312,6 +313,8 @@ class ApiRouter {
|
||||
this.router.post('/genres/rename', MiscController.renameGenre.bind(this))
|
||||
this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))
|
||||
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
|
||||
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
||||
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
|
||||
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
|
||||
}
|
||||
|
||||
|
344
test/server/finders/BookFinder.test.js
Normal file
344
test/server/finders/BookFinder.test.js
Normal file
@ -0,0 +1,344 @@
|
||||
const sinon = require('sinon')
|
||||
const chai = require('chai')
|
||||
const expect = chai.expect
|
||||
const bookFinder = require('../../../server/finders/BookFinder')
|
||||
const { LogLevel } = require('../../../server/utils/constants')
|
||||
const Logger = require('../../../server/Logger')
|
||||
Logger.setLogLevel(LogLevel.INFO)
|
||||
|
||||
describe('TitleCandidates', () => {
|
||||
describe('cleanAuthor non-empty', () => {
|
||||
let titleCandidates
|
||||
const cleanAuthor = 'leo tolstoy'
|
||||
|
||||
beforeEach(() => {
|
||||
titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)
|
||||
})
|
||||
|
||||
describe('no adds', () => {
|
||||
it('returns no candidates', () => {
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds candidate', 'anna karenina', ['anna karenina']],
|
||||
['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']],
|
||||
['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']],
|
||||
['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']],
|
||||
['does not add empty candidate after removing author', cleanAuthor, []],
|
||||
['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']],
|
||||
['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']],
|
||||
['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']],
|
||||
['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']],
|
||||
['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']],
|
||||
['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']],
|
||||
['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']],
|
||||
['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']],
|
||||
['does not add empty candidate', '', []],
|
||||
['does not add spaces-only candidate', ' ', []],
|
||||
['does not add empty variant', '1984', ['1984']],
|
||||
].forEach(([name, title, expected]) => it(name, () => {
|
||||
titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
}))
|
||||
})
|
||||
|
||||
describe('multiple adds', () => {
|
||||
[
|
||||
['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']],
|
||||
['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']],
|
||||
['orders by position', ['title2', 'title1'], ['title2', 'title1']],
|
||||
['dedupes candidates', ['title1', 'title1'], ['title1']],
|
||||
].forEach(([name, titles, expected]) => it(name, () => {
|
||||
for (const title of titles) titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanAuthor empty', () => {
|
||||
let titleCandidates
|
||||
let cleanAuthor = ''
|
||||
|
||||
beforeEach(() => {
|
||||
titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds a candidate', 'leo tolstoy', ['leo tolstoy']],
|
||||
].forEach(([name, title, expected]) => it(name, () => {
|
||||
titleCandidates.add(title)
|
||||
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthorCandidates', () => {
|
||||
let authorCandidates
|
||||
const audnexus = {
|
||||
authorASINsRequest: sinon.stub().resolves([
|
||||
{ name: 'Leo Tolstoy' },
|
||||
{ name: 'Nikolai Gogol' },
|
||||
{ name: 'J. K. Rowling' },
|
||||
]),
|
||||
}
|
||||
|
||||
describe('cleanAuthor is null', () => {
|
||||
beforeEach(() => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus)
|
||||
})
|
||||
|
||||
describe('no adds', () => {
|
||||
[
|
||||
['returns empty author candidate', []],
|
||||
].forEach(([name, expected]) => it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']],
|
||||
['does not add unrecognized candidate', 'fyodor dostoevsky', []],
|
||||
['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']],
|
||||
['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']],
|
||||
['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']],
|
||||
['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []],
|
||||
['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']],
|
||||
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']],
|
||||
].forEach(([name, author, expected]) => it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
|
||||
describe('multi add', () => {
|
||||
[
|
||||
['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']],
|
||||
['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']],
|
||||
].forEach(([name, authors, expected]) => it(name, async () => {
|
||||
for (const author of authors) authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanAuthor is a recognized author', () => {
|
||||
const cleanAuthor = 'leo tolstoy'
|
||||
|
||||
beforeEach(() => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
})
|
||||
|
||||
describe('no adds', () => {
|
||||
[
|
||||
['adds cleanAuthor as candidate', [cleanAuthor]],
|
||||
].forEach(([name, expected]) => it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']],
|
||||
['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]],
|
||||
].forEach(([name, author, expected]) => it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanAuthor is an unrecognized author', () => {
|
||||
const cleanAuthor = 'Fyodor Dostoevsky'
|
||||
|
||||
beforeEach(() => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
})
|
||||
|
||||
describe('no adds', () => {
|
||||
[
|
||||
['adds cleanAuthor as candidate', [cleanAuthor]],
|
||||
].forEach(([name, expected]) => it(name, async () => {
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']],
|
||||
['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]],
|
||||
].forEach(([name, author, expected]) => it(name, async () => {
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanAuthor is unrecognized and dirty', () => {
|
||||
describe('no adds', () => {
|
||||
[
|
||||
['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']],
|
||||
['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']],
|
||||
].forEach(([name, cleanAuthor, expected]) => it(name, async () => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
|
||||
describe('single add', () => {
|
||||
[
|
||||
['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']],
|
||||
].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => {
|
||||
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
|
||||
authorCandidates.add(author)
|
||||
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
const t = 'title'
|
||||
const a = 'author'
|
||||
const u = 'unrecognized'
|
||||
const r = ['book']
|
||||
|
||||
const runSearchStub = sinon.stub(bookFinder, 'runSearch')
|
||||
runSearchStub.resolves([])
|
||||
runSearchStub.withArgs(t, a).resolves(r)
|
||||
runSearchStub.withArgs(t, u).resolves(r)
|
||||
|
||||
const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')
|
||||
audnexusStub.resolves([{ name: a }])
|
||||
|
||||
beforeEach(() => {
|
||||
bookFinder.runSearch.resetHistory()
|
||||
})
|
||||
|
||||
describe('search title is empty', () => {
|
||||
it('returns empty result', async () => {
|
||||
expect(await bookFinder.search('', '', a)).to.deep.equal([])
|
||||
sinon.assert.callCount(bookFinder.runSearch, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search title is a recognized title and search author is a recognized author', () => {
|
||||
it('returns non-empty result (no fuzzy searches)', async () => {
|
||||
expect(await bookFinder.search('', t, a)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search title contains recognized title and search author is a recognized author', () => {
|
||||
[
|
||||
[`${t} -`],
|
||||
[`${t} - ${a}`],
|
||||
[`${a} - ${t}`],
|
||||
[`${t}- ${a}`],
|
||||
[`${t} -${a}`],
|
||||
[`${t} ${a}`],
|
||||
[`${a} - ${t} (unabridged)`],
|
||||
[`${a} - ${t} (subtitle) - mp3`],
|
||||
[`${t} {narrator} - series-01 64kbps 10:00:00`],
|
||||
[`${a} - ${t} (2006) narrated by narrator [unabridged]`],
|
||||
[`${t} - ${a} 2022 mp3`],
|
||||
[`01 ${t}`],
|
||||
[`2022_${t}_HQ`],
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
|
||||
[`${a} - series 01 - ${t}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 3)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`${t}-${a}`],
|
||||
[`${t} junk`],
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxFuzzySearches = 0', () => {
|
||||
[
|
||||
[`${t} - ${a}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([])
|
||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxFuzzySearches = 1', () => {
|
||||
[
|
||||
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
|
||||
[`${a} - series 01 - ${t}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([])
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('search title contains recognized title and search author is empty', () => {
|
||||
[
|
||||
[`${t} - ${a}`],
|
||||
[`${a} - ${t}`],
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`${t}`],
|
||||
[`${t} - ${u}`],
|
||||
[`${u} - ${t}`]
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '') returns an empty result`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('search title contains recognized title and search author is an unrecognized author', () => {
|
||||
[
|
||||
[`${t} - ${u}`],
|
||||
[`${u} - ${t}`]
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||
})
|
||||
});
|
||||
|
||||
[
|
||||
[`${t}`]
|
||||
].forEach(([searchTitle]) => {
|
||||
it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {
|
||||
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
|
||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user