@@ -57,21 +57,17 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
-### Build your own tools & clients
-
-Check out the [API documentation](https://api.audiobookshelf.org/)
-
-# Organizing your audiobooks
+# Organizing your media
#### Directory structure and folder names are important to Audiobookshelf!
-See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
+See [library docs](https://audiobookshelf.org/docs/category/libraries) for supported directory structures, folder naming conventions, and audio file metadata usage.
@@ -87,275 +83,24 @@ See [install docs](https://www.audiobookshelf.org/docs)
#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)
-### NGINX Proxy Manager
+See [reverse proxy docs](https://audiobookshelf.org/docs/category/reverse-proxy)
-Toggle websockets support.
-
-
-
-### NGINX Reverse Proxy
-
-Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
-
-```bash
-server {
- listen 443 ssl;
- server_name ..;
-
- access_log /var/log/nginx/audiobookshelf.access.log;
- error_log /var/log/nginx/audiobookshelf.error.log;
-
- ssl_certificate /path/to/certificate;
- ssl_certificate_key /path/to/key;
-
- location / {
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header Host $http_host;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
-
- proxy_http_version 1.1;
-
- proxy_pass http://;
- proxy_redirect http:// https://;
-
- # Prevent 413 Request Entity Too Large error
- # by increasing the maximum allowed size of the client request body
- # For example, set it to 10 GiB
- client_max_body_size 10240M;
- }
-}
-```
-
-### Apache Reverse Proxy
-
-Add this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
-
-For this to work you must enable at least the following mods using `a2enmod`:
-
-- `ssl`
-- `proxy`
-- `proxy_http`
-- `proxy_balancer`
-- `proxy_wstunnel`
-- `rewrite`
-
-```bash
-
-
- ServerName ..
-
- ErrorLog ${APACHE_LOG_DIR}/error.log
- CustomLog ${APACHE_LOG_DIR}/access.log combined
-
- ProxyPreserveHost On
- ProxyPass / http://localhost:/
- RewriteEngine on
- RewriteCond %{HTTP:Upgrade} websocket [NC]
- RewriteCond %{HTTP:Connection} upgrade [NC]
- RewriteRule ^/?(.*) "ws://localhost:/$1" [P,L]
-
- # unless you're doing something special this should be generated by a
- # tool like certbot by let's encrypt
- SSLCertificateFile /path/to/cert/file
- SSLCertificateKeyFile /path/to/key/file
-
-
-```
-
-If using Apache >= 2.4.47 you can use the following, without having to use any of the `RewriteEngine`, `RewriteCond`, or `RewriteRule` directives. For example:
-
-```xml
-
- ProxyPreserveHost on
- ProxyPass http://localhost:/audiobookshelf upgrade=websocket
- ProxyPassReverse http://localhost:/audiobookshelf
-
-```
-
-Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead serve that directly:
-
-```bash
-
- # ...
-
- # create the directory structure /.well-known/acme-challenges
- # within DocumentRoot and give the HTTP user recursive write
- # access to it.
- DocumentRoot /path/to/local/directory
-
- ProxyPreserveHost On
- ProxyPass /.well-known !
- ProxyPass / http://localhost:/
-
- # ...
-
-```
-
-### SWAG Reverse Proxy
-
-[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
-
-### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)
-
-1. **Open Control Panel**
-
- - Navigate to `Login Portal > Advanced`.
-
-2. **General Tab**
-
- - Click `Reverse Proxy` > `Create`.
-
- | Setting | Value |
- | ------------------ | -------------- |
- | Reverse Proxy Name | audiobookshelf |
-
-3. **Source Configuration**
-
- | Setting | Value |
- | ---------------------- | ---------------------------------------- |
- | Protocol | HTTPS |
- | Hostname | `..synology.me` |
- | Port | 443 |
- | Access Control Profile | Leave as is |
-
- - Example Hostname: `audiobookshelf.mydomain.synology.me`
-
-4. **Destination Configuration**
-
- | Setting | Value |
- | -------- | ----------- |
- | Protocol | HTTP |
- | Hostname | Your NAS IP |
- | Port | 13378 |
-
-5. **Custom Header Tab**
-
- - Go to `Create > Websocket`.
- - Configure Headers (leave as is):
-
- | Header Name | Value |
- | ----------- | --------------------- |
- | Upgrade | `$http_upgrade` |
- | Connection | `$connection_upgrade` |
-
-6. **Advanced Settings Tab**
- - Leave as is.
-
-### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
-
-Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
-
-
-
accessControlAllowMethods
-
accessControlAllowOriginList
-
accessControlMaxAge
-
-
-From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
-
-### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
-
-```
-subdomain.domain.com {
- encode gzip zstd
- reverse_proxy :
-}
-```
-
-### HAProxy
-
-Below is a generic HAProxy config, using `audiobookshelf.YOUR_DOMAIN.COM`.
-
-To use `http2`, `ssl` is needed.
-
-```make
-global
- # ... (your global settings go here)
-
-defaults
- mode http
- # ... (your default settings go here)
-
-frontend my_frontend
- # Bind to port 443, enable SSL, and specify the certificate list file
- bind :443 name :443 ssl crt-list /path/to/cert.crt_list alpn h2,http/1.1
- mode http
-
- # Define an ACL for subdomains starting with "audiobookshelf"
- acl is_audiobookshelf hdr_beg(host) -i audiobookshelf
-
- # Use the ACL to route traffic to audiobookshelf_backend if the condition is met,
- # otherwise, use the default_backend
- use_backend audiobookshelf_backend if is_audiobookshelf
- default_backend default_backend
-
-backend audiobookshelf_backend
- mode http
- # ... (backend settings for audiobookshelf go here)
-
- # Define the server for the audiobookshelf backend
- server audiobookshelf_server 127.0.0.99:13378
-
-backend default_backend
- mode http
- # ... (default backend settings go here)
-
- # Define the server for the default backend
- server default_server 127.0.0.123:8081
-
-```
-
-### pfSense and HAProxy
-
-For pfSense the inputs are graphical, and `Health checking` is enabled.
-
-#### Frontend, Default backend, access control lists and actions
-
-##### Access Control lists
-
-| Name | Expression | CS | Not | Value |
-| :------------: | :---------------: | :-: | :-: | :-------------: |
-| audiobookshelf | Host starts with: | | | audiobookshelf. |
-
-##### Actions
-
-The `condition acl names` needs to match the name above `audiobookshelf`.
-
-| Action | Parameters | Condition acl names |
-| :-----------: | :------------: | :-----------------: |
-| `Use Backend` | audiobookshelf | audiobookshelf |
-
-#### Backend
-
-The `Name` needs to match the `Parameters` above `audiobookshelf`.
-
-| Name | audiobookshelf |
-| ---- | -------------- |
-
-##### Server list:
-
-| Name | Expression | CS | Not | Value |
-| :------------: | :---------------: | :-: | :-: | :-------------: |
-| audiobookshelf | Host starts with: | | | audiobookshelf. |
-
-##### Health checking:
-
-Health checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. If Health check fails, data will not be forwared. Need to do one of following:
-
-- To disable: Change `Health check method` to `none`.
-- To make Health checking function: Change `Http check method` to `HEAD` or `GET`.
-
-# Run from source
+
# Contributing
-This application is built using [NodeJs](https://nodejs.org/).
+See [contributing docs](https://audiobookshelf.org/docs/contributing/general/)
### Localization
-Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations).
+Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations).
+
+
+
+
+# Run from source
+
+This application is built using [NodeJs](https://nodejs.org/).
### Dev Container Setup
@@ -447,6 +192,3 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
- `Debug client (nuxt)`âRun the client with live reload.
- `Debug server and client (nuxt)`âRuns both the preceding two debug targets.
-# How to Support
-
-[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
diff --git a/server/Auth.js b/server/Auth.js
index f63e84460..17bd1160f 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -81,7 +81,7 @@ class Auth {
* @param {import('./models/User')} user
* @param {Request} req
* @param {Response} res
- * @returns {Promise} accessToken only if user is current user and refresh token is valid
+ * @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
*/
async invalidateJwtSessionsForUser(user, req, res) {
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
@@ -471,18 +471,23 @@ class Auth {
res.json(openIdIssuerConfig)
})
- // Logout route
+ /**
+ * Logout route
+ * Use ?allDevices=1 to destroy every session for this user instead of just the current one
+ */
router.post('/logout', async (req, res) => {
// Refresh token be alternatively be sent in the header
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
+ const allDevices = req.query.allDevices === '1'
// Clear refresh token cookie
res.clearCookie('refresh_token', {
path: '/'
})
- // Invalidate the session in database using refresh token
- if (refreshToken) {
+ if (allDevices) {
+ await this.tokenManager.invalidateAllSessionsForRefreshToken(refreshToken)
+ } else if (refreshToken) {
await this.tokenManager.invalidateRefreshToken(refreshToken)
} else {
Logger.info(`[Auth] logout: No refresh token on request`)
diff --git a/server/Server.js b/server/Server.js
index d6f748a1e..c1657ff1e 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -14,6 +14,7 @@ const { version } = require('../package.json')
const is = require('./libs/requestIp/isJs')
const fileUtils = require('./utils/fileUtils')
const { toNumber } = require('./utils/index')
+const { getRequestOrigin } = require('./utils/requestUtils')
const Logger = require('./Logger')
const Auth = require('./Auth')
@@ -288,10 +289,9 @@ class Server {
// if RouterBasePath is set, modify all requests to include the base path
app.use((req, res, next) => {
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
- const host = req.get('host')
- const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
+ const { origin } = getRequestOrigin(req)
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
- req.originalHostPrefix = `${protocol}://${host}${prefix}`
+ req.originalHostPrefix = `${origin}${prefix}`
if (!urlStartsWithRouterBasePath) {
req.url = `${global.RouterBasePath}${req.url}`
}
@@ -302,7 +302,9 @@ class Server {
this.server = http.createServer(app)
+ // Skip file upload parsing for internal-api routes (Next.js proxies read multipart bodies).
router.use(
+ /^(?!\/internal-api).*/,
fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js
index ad55f6605..a54231d75 100644
--- a/server/SocketAuthority.js
+++ b/server/SocketAuthority.js
@@ -3,6 +3,7 @@ const Logger = require('./Logger')
const Database = require('./Database')
const TokenManager = require('./auth/TokenManager')
const CoverSearchManager = require('./managers/CoverSearchManager')
+const { LogLevel } = require('./utils/constants')
/**
* @typedef SocketClient
@@ -85,6 +86,14 @@ class SocketAuthority {
}
}
+ requireAdminSocket(socket, eventName) {
+ const client = this.clients[socket.id]
+ if (client?.user?.isAdminOrUp) return true
+
+ Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`)
+ return false
+ }
+
/**
* Emits event with library item to all clients that can access the library item
* Note: Emits toOldJSONExpanded()
@@ -179,14 +188,25 @@ class SocketAuthority {
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
- socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
+ socket.on('cancel_scan', (libraryId) => {
+ if (!this.requireAdminSocket(socket, 'cancel_scan')) return
+ this.cancelScan(libraryId)
+ })
// Cover search streaming
socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))
socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))
// Logs
- socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
+ socket.on('set_log_listener', (level) => {
+ if (!this.requireAdminSocket(socket, 'set_log_listener')) return
+
+ if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) {
+ Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`)
+ return
+ }
+ Logger.addSocketListener(socket, level)
+ })
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// Sent automatically from socket.io clients
diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js
index 64ab82448..0997b7cf3 100644
--- a/server/auth/OidcAuthStrategy.js
+++ b/server/auth/OidcAuthStrategy.js
@@ -4,6 +4,7 @@ const OpenIDClient = require('openid-client')
const axios = require('axios')
const Database = require('../Database')
const Logger = require('../Logger')
+const { getRequestOrigin } = require('../utils/requestUtils')
/**
* OpenID Connect authentication strategy
@@ -289,8 +290,8 @@ class OidcAuthStrategy {
const sessionKey = strategy._key
try {
- const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
- const hostUrl = new URL(`${protocol}://${req.get('host')}`)
+ const { origin } = getRequestOrigin(req)
+ const hostUrl = new URL(origin)
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
// Only allow code flow (for mobile clients)
@@ -394,11 +395,10 @@ class OidcAuthStrategy {
let postLogoutRedirectUri = null
if (authMethod === 'openid') {
- const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
- const host = req.get('host')
+ const { origin } = getRequestOrigin(req)
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
- postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
+ postLogoutRedirectUri = `${origin}${global.RouterBasePath}/login`
}
// else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement
@@ -515,42 +515,33 @@ class OidcAuthStrategy {
if (!callbackUrl) return false
try {
- // Handle relative URLs - these are always safe if they start with router base path
- if (callbackUrl.startsWith('/')) {
- // Only allow relative paths that start with the router base path
- if (callbackUrl.startsWith(global.RouterBasePath + '/')) {
- return true
- }
+ // Reject protocol-relative (//host) and backslash-prefixed (/\host) values,
+ // which browsers resolve to a cross-origin absolute URL.
+ if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) {
+ Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`)
+ return false
+ }
+
+ const { origin: serverOrigin } = getRequestOrigin(req)
+ const resolvedUrl = callbackUrl.startsWith('/') ? new URL(callbackUrl, serverOrigin) : new URL(callbackUrl)
+
+ if (resolvedUrl.origin !== serverOrigin) {
+ Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${serverOrigin})`)
+ return false
+ }
+
+ const pathname = decodeURIComponent(resolvedUrl.pathname)
+ if (pathname.startsWith('//') || pathname.startsWith('/\\')) {
+ Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL path: ${callbackUrl}`)
+ return false
+ }
+
+ if (!resolvedUrl.pathname.startsWith(global.RouterBasePath + '/')) {
Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
return false
}
- // For absolute URLs, ensure they point to the same origin
- const callbackUrlObj = new URL(callbackUrl)
- // NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both
- const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
- const currentProtocol =
- req.secure ||
- xfp
- .split(',')
- .map((s) => s.trim())
- .includes('https')
- ? 'https'
- : 'http'
- const currentHost = req.get('host')
-
- // Check if protocol and host match exactly
- if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) {
- // Additional check: ensure path starts with router base path
- if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) {
- return true
- }
- Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`)
- return false
- }
-
- Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`)
- return false
+ return true
} catch (error) {
Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
return false
diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js
index d972b5345..1f0417622 100644
--- a/server/auth/TokenManager.js
+++ b/server/auth/TokenManager.js
@@ -1,10 +1,12 @@
const { Op } = require('sequelize')
+const uuid = require('uuid')
const Database = require('../Database')
const Logger = require('../Logger')
const requestIp = require('../libs/requestIp')
const jwt = require('../libs/jsonwebtoken')
+const { isRequestSecure } = require('../utils/requestUtils')
class TokenManager {
/** @type {string} JWT secret key */
@@ -12,9 +14,11 @@ class TokenManager {
constructor() {
/** @type {number} Refresh token expiry in seconds */
- this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
+ this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
/** @type {number} Access token expiry in seconds */
- this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
+ this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
+ /** @type {number} Grace period in seconds during which a rotated (old) refresh token is still accepted */
+ this.RefreshTokenGracePeriod = parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) || 10 * 60 // 10 minutes
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
@@ -22,6 +26,9 @@ class TokenManager {
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
}
+ if (parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) > 0) {
+ Logger.info(`[TokenManager] Refresh token grace period set from ENV variable to ${this.RefreshTokenGracePeriod} seconds`)
+ }
}
get TokenSecret() {
@@ -58,13 +65,25 @@ class TokenManager {
setRefreshTokenCookie(req, res, refreshToken) {
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
- secure: req.secure || req.get('x-forwarded-proto') === 'https',
+ secure: isRequestSecure(req),
sameSite: 'lax',
maxAge: this.RefreshTokenExpiry * 1000,
path: '/'
})
}
+ /**
+ * Whether a decoded JWT payload may authenticate API/socket requests (not refresh-only credentials).
+ *
+ * @param {Object} decoded
+ * @returns {boolean}
+ */
+ static isBearerAccessTokenPayload(decoded) {
+ if (!decoded?.userId) return false
+ if (decoded.type === 'refresh') return false
+ return true
+ }
+
/**
* Function to validate a jwt token for a given user
* Used to authenticate socket connections
@@ -75,7 +94,9 @@ class TokenManager {
*/
static validateAccessToken(token) {
try {
- return jwt.verify(token, TokenManager.TokenSecret)
+ const decoded = jwt.verify(token, TokenManager.TokenSecret)
+ if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
+ return decoded
} catch (err) {
return null
}
@@ -115,6 +136,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
+ jti: uuid.v4(),
type: 'access'
}
const options = {
@@ -138,6 +160,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
+ jti: uuid.v4(),
type: 'refresh'
}
const options = {
@@ -183,20 +206,60 @@ class TokenManager {
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
+ * @param {boolean} gracePeriod - whether to use the grace period
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/
- async rotateTokensForSession(session, user, req, res) {
- // Generate new tokens
+ async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
+ const previousRefreshToken = session.refreshToken
const newAccessToken = this.generateTempAccessToken(user)
- const newRefreshToken = this.generateRefreshToken(user)
-
- // Calculate new expiration time
+ let newRefreshToken = this.generateRefreshToken(user)
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
- // Update the session with the new refresh token and expiration
- session.refreshToken = newRefreshToken
- session.expiresAt = newExpiresAt
- await session.save()
+ let lastRefreshToken = null
+ let lastRefreshTokenExpiresAt = null
+ if (gracePeriod) {
+ // Set grace period of old refresh token in case of race condition in token rotation.
+ // During this window a retry with the old refresh token returns the already-rotated
+ // current token instead of failing, so a client that never received the rotation
+ // response (e.g. dropped/suspended mobile request) can still recover the session.
+ // Configurable via REFRESH_TOKEN_GRACE_PERIOD; may need to be longer if fetching the
+ // user data takes longer due to large progress objects.
+ lastRefreshToken = previousRefreshToken
+ lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
+ }
+
+ // Only update if this session row still has the refresh token we read
+ const [numUpdated] = await Database.sessionModel.update(
+ {
+ refreshToken: newRefreshToken,
+ expiresAt: newExpiresAt,
+ lastRefreshToken,
+ lastRefreshTokenExpiresAt
+ },
+ {
+ where: {
+ id: session.id,
+ refreshToken: previousRefreshToken
+ }
+ }
+ )
+
+ if (numUpdated === 0) {
+ Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
+
+ const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
+
+ newRefreshToken = updatedSession.refreshToken
+ session.refreshToken = updatedSession.refreshToken
+ session.expiresAt = updatedSession.expiresAt
+ session.lastRefreshToken = updatedSession.lastRefreshToken
+ session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
+ } else {
+ session.refreshToken = newRefreshToken
+ session.expiresAt = newExpiresAt
+ session.lastRefreshToken = lastRefreshToken
+ session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
+ }
// Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken)
@@ -234,9 +297,20 @@ class TokenManager {
}
const user = await Database.userModel.getUserById(apiKey.userId)
+
+ if (!user?.isActive) {
+ // deny login
+ done(null, null)
+ return
+ }
+
done(null, user)
} else {
- // JWT based authentication
+ // JWT based authentication â refresh tokens are only valid at POST /auth/refresh
+ if (!TokenManager.isBearerAccessTokenPayload(jwt_payload)) {
+ done(null, null)
+ return
+ }
// Check if the jwt is expired
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
@@ -287,23 +361,40 @@ class TokenManager {
}
}
- const session = await Database.sessionModel.findOne({
- where: { refreshToken: refreshToken }
+ let session = await Database.sessionModel.findOne({
+ where: {
+ [Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
+ }
})
if (!session) {
- Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
+ Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
return {
error: 'Invalid refresh token'
}
}
- // Check if session is expired in database
- if (session.expiresAt < new Date()) {
- Logger.info(`[TokenManager] Session expired in database, cleaning up`)
- await session.destroy()
- return {
- error: 'Refresh token expired'
+ let isGracePeriod = false
+ if (session.refreshToken !== refreshToken) {
+ // Token matched lastRefreshToken
+ if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
+ isGracePeriod = true
+ Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
+ } else {
+ Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
+ return {
+ error: 'Invalid refresh token'
+ }
+ }
+ } else {
+ // Token matched current refreshToken
+ // Check if session is expired in database
+ if (session.expiresAt < new Date()) {
+ Logger.info(`[TokenManager] Session expired in database, cleaning up`)
+ await session.destroy()
+ return {
+ error: 'Refresh token expired'
+ }
}
}
@@ -315,6 +406,20 @@ class TokenManager {
}
}
+ if (isGracePeriod) {
+ // Return the already rotated refresh token store in the database,
+ // and generate a new access token without changing the refresh token
+ // again
+ const accessToken = this.generateTempAccessToken(user)
+ this.setRefreshTokenCookie(req, res, session.refreshToken)
+
+ return {
+ accessToken,
+ refreshToken: session.refreshToken,
+ user
+ }
+ }
+
const newTokens = await this.rotateTokensForSession(session, user, req, res)
return {
accessToken: newTokens.accessToken,
@@ -359,16 +464,21 @@ class TokenManager {
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
- * @returns {Promise} accessToken only if user is current user and refresh token is valid
+ * @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
*/
async invalidateJwtSessionsForUser(user, req, res) {
- const currentRefreshToken = req.cookies.refresh_token
+ const currentRefreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
if (req.user.id === user.id && currentRefreshToken) {
// Current user is the same as the user to invalidate sessions for
// So rotate token for current session
- const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
+ const currentSession = await Database.sessionModel.findOne({
+ where: {
+ userId: user.id,
+ [Op.or]: [{ refreshToken: currentRefreshToken }, { lastRefreshToken: currentRefreshToken }]
+ }
+ })
if (currentSession) {
- const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
+ const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
// Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({
@@ -380,9 +490,12 @@ class TokenManager {
}
})
- return newTokens.accessToken
+ return {
+ accessToken: newTokens.accessToken,
+ refreshToken: newTokens.refreshToken
+ }
} else {
- Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
+ Logger.error(`[TokenManager] No session found to rotate tokens`)
}
}
@@ -392,6 +505,25 @@ class TokenManager {
return null
}
+ /**
+ * Destroy all JWT sessions for the user that owns this refresh token
+ *
+ * @param {string} refreshToken
+ */
+ async invalidateAllSessionsForRefreshToken(refreshToken) {
+ if (!refreshToken) return
+
+ const session = await Database.sessionModel.findOne({
+ where: {
+ [Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
+ }
+ })
+ if (!session) return
+
+ const numDeleted = await Database.sessionModel.destroy({ where: { userId: session.userId } })
+ Logger.info(`[TokenManager] Invalidated all JWT sessions for user ${session.userId}, ${numDeleted} deleted`)
+ }
+
/**
* Invalidate a refresh token - used for logout
*
@@ -406,7 +538,7 @@ class TokenManager {
try {
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
- Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
+ Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`)
return true
} catch (error) {
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js
index 50eeda31a..8c2e80aec 100644
--- a/server/controllers/AuthorController.js
+++ b/server/controllers/AuthorController.js
@@ -10,7 +10,7 @@ const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const AuthorFinder = require('../finders/AuthorFinder')
-const { reqSupportsWebp, isValidASIN } = require('../utils/index')
+const { reqSupportsWebp, isValidASIN, clampPositiveInt } = require('../utils/index')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@@ -113,7 +113,7 @@ class AuthorController {
payload.lastFirst = Database.authorModel.getLastFirst(payload.name)
}
- // Check if author name matches another author and merge the authors
+ // Check if author name matches another author in the same library and merge the authors
let existingAuthor = null
if (authorNameUpdate) {
existingAuthor = await Database.authorModel.findOne({
@@ -121,7 +121,8 @@ class AuthorController {
id: {
[sequelize.Op.not]: req.author.id
},
- name: payload.name
+ name: payload.name,
+ libraryId: req.author.libraryId
}
})
}
@@ -148,7 +149,7 @@ class AuthorController {
})
if (libraryItems.length) {
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
- await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
+ await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile()
}
@@ -411,8 +412,8 @@ class AuthorController {
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
- height: height ? parseInt(height) : null,
- width: width ? parseInt(width) : null
+ height: clampPositiveInt(height ? parseInt(height) : null, 4096),
+ width: clampPositiveInt(width ? parseInt(width) : null, 4096)
}
return CacheManager.handleAuthorCache(res, authorId, options)
}
diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js
index 475adfe0f..bb00ea346 100644
--- a/server/controllers/CollectionController.js
+++ b/server/controllers/CollectionController.js
@@ -3,6 +3,7 @@ const Sequelize = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
+const htmlSanitizer = require('../utils/htmlSanitizer')
const RssFeedManager = require('../managers/RssFeedManager')
@@ -31,13 +32,19 @@ class CollectionController {
async create(req, res) {
const reqBody = req.body || {}
+ const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
+
// Validation
- if (!reqBody.name || !reqBody.libraryId) {
+ if (!nameCleaned || !reqBody.libraryId) {
return res.status(400).send('Invalid collection data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description')
}
+ if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
+ Logger.warn(`[CollectionController] User "${req.user.username}" attempted to create collection in inaccessible library ${reqBody.libraryId}`)
+ return res.sendStatus(403)
+ }
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books')
@@ -65,7 +72,7 @@ class CollectionController {
newCollection = await Database.collectionModel.create(
{
libraryId: reqBody.libraryId,
- name: reqBody.name,
+ name: nameCleaned,
description: reqBody.description || null
},
{ transaction }
@@ -106,8 +113,9 @@ class CollectionController {
*/
async findAll(req, res) {
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
+ const accessibleCollections = collectionsExpanded.filter((c) => req.user.checkCanAccessLibrary(c.libraryId))
res.json({
- collections: collectionsExpanded
+ collections: accessibleCollections
})
}
@@ -145,9 +153,12 @@ class CollectionController {
collectionUpdatePayload.description = req.body.description
wasUpdated = true
}
- if (req.body.name !== undefined && req.body.name !== req.collection.name) {
- collectionUpdatePayload.name = req.body.name
- wasUpdated = true
+ if (req.body.name !== undefined && typeof req.body.name === 'string') {
+ const nameCleaned = htmlSanitizer.stripAllTags(req.body.name)
+ if (nameCleaned !== req.collection.name) {
+ collectionUpdatePayload.name = nameCleaned
+ wasUpdated = true
+ }
}
if (wasUpdated) {
@@ -425,6 +436,10 @@ class CollectionController {
if (!collection) {
return res.status(404).send('Collection not found')
}
+ if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
+ Logger.warn(`[CollectionController] User "${req.user.username}" attempted to access collection ${collection.id} in inaccessible library ${collection.libraryId}`)
+ return res.status(404).send('Collection not found')
+ }
req.collection = collection
}
diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js
index 4b0a94b39..41e082fd4 100644
--- a/server/controllers/FileSystemController.js
+++ b/server/controllers/FileSystemController.js
@@ -117,7 +117,7 @@ class FileSystemController {
filepath = fileUtils.filePathToPOSIX(filepath)
// Ensure filepath is inside library folder (prevents directory traversal)
- if (!filepath.startsWith(libraryFolder.path)) {
+ if (!fileUtils.isSameOrSubPath(libraryFolder.path, filepath)) {
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
return res.sendStatus(400)
}
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index e63441f0b..71f210106 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -221,13 +221,11 @@ class LibraryController {
const includeArray = (req.query.include || '').split(',')
if (includeArray.includes('filterdata')) {
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
- const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType)
return res.json({
filterdata,
issues: filterdata.numIssues,
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
- customMetadataProviders,
library: req.library.toOldJSON()
})
}
@@ -464,7 +462,7 @@ class LibraryController {
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
- await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
+ await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
if (authorIds.length) {
@@ -565,7 +563,7 @@ class LibraryController {
mediaItemIds.push(libraryItem.mediaId)
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
- await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
+ await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
// Set PlaybackSessions libraryId to null
@@ -716,7 +714,7 @@ class LibraryController {
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
- await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
+ await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
if (authorIds.length) {
@@ -1434,13 +1432,21 @@ class LibraryController {
const itemIds = req.query.ids.split(',')
- const libraryItems = await Database.libraryItemModel.findAll({
- attributes: ['id', 'libraryId', 'path', 'isFile'],
- where: {
- id: itemIds
- }
+ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
+ id: itemIds,
+ libraryId: req.library.id
})
+ for (const libraryItem of libraryItems) {
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
+ return res.sendStatus(403)
+ }
+ }
+
+ if (libraryItems.length < itemIds.length) {
+ Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
+ }
+
Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`)
const filename = `LibraryItems-${Date.now()}.zip`
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 5247dbb06..07b4ee67f 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -1,13 +1,14 @@
const { Request, Response, NextFunction } = require('express')
const Path = require('path')
const fs = require('../libs/fsExtra')
+const cron = require('../libs/nodeCron')
const uaParserJs = require('../libs/uaParser')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers')
-const { reqSupportsWebp } = require('../utils/index')
+const { reqSupportsWebp, clampPositiveInt } = require('../utils/index')
const { ScanResult, AudioMimeType } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
@@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager')
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/
+/**
+ * Enforce per-item access for batch item routes
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ * @param {import('../models/LibraryItem')[]} libraryItems
+ * @returns {boolean} true if the user may access every item; false if 403 was sent
+ */
+function ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems) {
+ for (const libraryItem of libraryItems) {
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
+ res.sendStatus(403)
+ return false
+ }
+ }
+ return true
+}
+
class LibraryItemController {
constructor() {}
@@ -111,7 +130,7 @@ class LibraryItemController {
}
}
- await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
+ await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds, req.libraryItem.libraryId)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@@ -202,6 +221,11 @@ class LibraryItemController {
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true
}
+
+ if (mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
+ Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
+ return res.status(400).send('Invalid auto download schedule cron expression')
+ }
}
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
@@ -398,8 +422,8 @@ class LibraryItemController {
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
- height: height ? parseInt(height) : null,
- width: width ? parseInt(width) : null
+ height: clampPositiveInt(height ? parseInt(height) : null, 4096),
+ width: clampPositiveInt(width ? parseInt(width) : null, 4096)
}
return CacheManager.handleCoverCache(res, libraryItemId, options)
}
@@ -547,7 +571,13 @@ class LibraryItemController {
return res.sendStatus(404)
}
+ // Ensure user has permission to delete these library items
+ if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
+ return
+ }
+
const libraryId = itemsToDelete[0].libraryId
+
for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
@@ -565,7 +595,7 @@ class LibraryItemController {
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
}
}
- await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
+ await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, libraryItem.libraryId)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@@ -581,6 +611,7 @@ class LibraryItemController {
}
await Database.resetLibraryIssuesFilterData(libraryId)
+
res.sendStatus(200)
}
@@ -593,6 +624,11 @@ class LibraryItemController {
* @param {Response} res
*/
async batchUpdate(req, res) {
+ if (!req.user.canUpdate) {
+ Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`)
+ return res.sendStatus(403)
+ }
+
const updatePayloads = req.body
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
@@ -615,6 +651,11 @@ class LibraryItemController {
return res.sendStatus(404)
}
+ // Ensure user has permission to update these library items
+ if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
+ return
+ }
+
let itemsUpdated = 0
const seriesIdsRemoved = []
@@ -624,6 +665,11 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
+ if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
+ Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`)
+ continue
+ }
+
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
@@ -695,6 +741,10 @@ class LibraryItemController {
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
+ // Ensure user has permission to access these library items
+ if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
+ return
+ }
res.json({
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
})
diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js
index 51773a5ad..b8f378bfa 100644
--- a/server/controllers/MeController.js
+++ b/server/controllers/MeController.js
@@ -1,10 +1,12 @@
const { Request, Response } = require('express')
+const { Op } = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { sort } = require('../libs/fastSort')
const { toNumber, isNullOrNaN } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
+const parseUserAgent = require('../utils/parsers/parseUserAgent')
/**
* @typedef RequestUserObject
@@ -26,6 +28,83 @@ class MeController {
res.json(req.user.toOldJSONForBrowser())
}
+ /**
+ * GET: /api/me/sessions
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async getSessions(req, res) {
+ if (req.user.isGuest) {
+ return res.json({ sessions: [] })
+ }
+
+ const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
+ const sessions = await Database.sessionModel.findAll({
+ where: {
+ userId: req.user.id,
+ expiresAt: { [Op.gt]: new Date() }
+ },
+ order: [['updatedAt', 'DESC']]
+ })
+
+ res.json({
+ sessions: sessions.map((session) => ({
+ id: session.id,
+ ipAddress: session.ipAddress,
+ userAgent: session.userAgent,
+ // For display convenience
+ deviceInfo: parseUserAgent(session.userAgent),
+ createdAt: session.createdAt?.valueOf() ?? null,
+ updatedAt: session.updatedAt?.valueOf() ?? null,
+ current: !!refreshToken && (session.refreshToken === refreshToken || session.lastRefreshToken === refreshToken)
+ }))
+ })
+ }
+
+ /**
+ * GET: /api/me/progress
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ getAllMediaProgress(req, res) {
+ const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || []
+ res.json({ mediaProgress })
+ }
+
+ /**
+ * GET: /api/me/bookmarks
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ getAllBookmarks(req, res) {
+ const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || []
+ res.json({ bookmarks })
+ }
+
+ /**
+ * GET: /api/me/bookmarks/:libraryItemId
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async getBookmarksForLibraryItem(req, res) {
+ const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
+ if (!libraryItem) {
+ return res.sendStatus(404)
+ }
+
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
+ Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`)
+ return res.sendStatus(403)
+ }
+
+ const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || []
+ res.json({ bookmarks })
+ }
+
/**
* GET: /api/me/listening-sessions
*
@@ -63,7 +142,7 @@ class MeController {
* @param {Response} res
*/
async getItemListeningSessions(req, res) {
- const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
+ const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
@@ -71,6 +150,12 @@ class MeController {
return res.sendStatus(404)
}
+ // Check if user has access to this library item
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
+ Logger.error(`[MeController] User "${req.user.username}" attempted to access listening sessions for library item "${req.params.libraryItemId}" without access`)
+ return res.sendStatus(403)
+ }
+
const mediaItemId = episode?.id || libraryItem.mediaId
let listeningSessions = await this.getUserItemListeningSessionsHelper(req.user.id, mediaItemId)
@@ -125,6 +210,13 @@ class MeController {
* @param {Response} res
*/
async removeMediaProgress(req, res) {
+ // Verify the media progress belongs to the current user
+ const mediaProgress = req.user.mediaProgresses.find((mp) => mp.id === req.params.id)
+ if (!mediaProgress) {
+ Logger.error(`[MeController] Media progress not found or does not belong to user "${req.user.username}"`)
+ return res.sendStatus(404)
+ }
+
await Database.mediaProgressModel.removeById(req.params.id)
req.user.mediaProgresses = req.user.mediaProgresses.filter((mp) => mp.id !== req.params.id)
@@ -192,7 +284,16 @@ class MeController {
* @param {Response} res
*/
async createBookmark(req, res) {
- if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
+ const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
+ if (!libraryItem) {
+ return res.sendStatus(404)
+ }
+
+ // Check if user has access to this library item
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
+ Logger.error(`[MeController] User "${req.user.username}" attempted to create bookmark for library item "${req.params.id}" without access`)
+ return res.sendStatus(403)
+ }
const { time, title } = req.body
if (isNullOrNaN(time)) {
@@ -216,7 +317,16 @@ class MeController {
* @param {Response} res
*/
async updateBookmark(req, res) {
- if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
+ const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
+ if (!libraryItem) {
+ return res.sendStatus(404)
+ }
+
+ // Check if user has access to this library item
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
+ Logger.error(`[MeController] User "${req.user.username}" attempted to update bookmark for library item "${req.params.id}" without access`)
+ return res.sendStatus(403)
+ }
const { time, title } = req.body
if (isNullOrNaN(time)) {
@@ -245,7 +355,16 @@ class MeController {
* @param {Response} res
*/
async removeBookmark(req, res) {
- if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
+ const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
+ if (!libraryItem) {
+ return res.sendStatus(404)
+ }
+
+ // Check if user has access to this library item
+ if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
+ Logger.error(`[MeController] User "${req.user.username}" attempted to remove bookmark for library item "${req.params.id}" without access`)
+ return res.sendStatus(403)
+ }
const time = Number(req.params.time)
if (isNaN(time)) {
@@ -268,6 +387,8 @@ class MeController {
* User change password. Requires current password.
* Guest users cannot change password.
*
+ * Invalidates all other JWT sessions for the user. If using x-refresh-token, returns new tokens for the current session.
+ *
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
@@ -290,6 +411,24 @@ class MeController {
return res.status(400).send(result.error)
}
+ const shouldReturnTokens = !!req.headers['x-refresh-token']
+ const newTokens = await this.auth.invalidateJwtSessionsForUser(req.user, req, res)
+
+ if (newTokens?.accessToken) {
+ Logger.info(`[MeController] Invalidated other JWT sessions for user ${req.user.username} after password change`)
+ if (shouldReturnTokens) {
+ return res.json({
+ success: true,
+ user: {
+ accessToken: newTokens.accessToken,
+ refreshToken: newTokens.refreshToken
+ }
+ })
+ }
+ } else {
+ Logger.info(`[MeController] Invalidated all JWT sessions for user ${req.user.username} after password change`)
+ }
+
res.sendStatus(200)
}
diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js
index c779bdd63..591f8ccf2 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -8,7 +8,7 @@ const Database = require('../Database')
const Watcher = require('../Watcher')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
-const patternValidation = require('../libs/nodeCron/pattern-validation')
+const cron = require('../libs/nodeCron')
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
@@ -142,6 +142,9 @@ class MiscController {
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
}
+ if (settingsUpdate.allowedOrigins && !Array.isArray(settingsUpdate.allowedOrigins)) {
+ return res.status(400).send('allowedOrigins must be an array')
+ }
const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) {
@@ -602,13 +605,11 @@ class MiscController {
return res.sendStatus(400)
}
- try {
- patternValidation(expression)
- res.sendStatus(200)
- } catch (error) {
- Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
- res.status(400).send(error.message)
+ if (!cron.validate(expression)) {
+ Logger.warn(`[MiscController] Invalid cron expression ${expression}`)
+ return res.status(400).send('Invalid cron expression')
}
+ res.sendStatus(200)
}
/**
diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js
index 972c352a4..6ad7cff9e 100644
--- a/server/controllers/PlaylistController.js
+++ b/server/controllers/PlaylistController.js
@@ -2,6 +2,7 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
+const htmlSanitizer = require('../utils/htmlSanitizer')
/**
* @typedef RequestUserObject
@@ -29,12 +30,17 @@ class PlaylistController {
const reqBody = req.body || {}
// Validation
- if (!reqBody.name || !reqBody.libraryId) {
+ const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
+ if (!nameCleaned || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
+ if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
+ Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist in inaccessible library ${reqBody.libraryId}`)
+ return res.sendStatus(403)
+ }
const items = reqBody.items || []
const isPodcast = items.some((i) => i.episodeId)
const libraryItemIds = new Set()
@@ -84,7 +90,7 @@ class PlaylistController {
{
libraryId: reqBody.libraryId,
userId: req.user.id,
- name: reqBody.name,
+ name: nameCleaned,
description: reqBody.description || null
},
{ transaction }
@@ -131,8 +137,9 @@ class PlaylistController {
*/
async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
+ const accessiblePlaylists = playlistsForUser.filter((p) => req.user.checkCanAccessLibrary(p.libraryId))
res.json({
- playlists: playlistsForUser
+ playlists: accessiblePlaylists
})
}
@@ -174,7 +181,11 @@ class PlaylistController {
}
const playlistUpdatePayload = {}
- if (reqBody.name) playlistUpdatePayload.name = reqBody.name
+
+ const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
+ if (nameCleaned) {
+ playlistUpdatePayload.name = nameCleaned
+ }
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
// Update name and description
@@ -502,6 +513,10 @@ class PlaylistController {
if (!collection) {
return res.status(404).send('Collection not found')
}
+ if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
+ Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist from collection ${collection.id} in inaccessible library ${collection.libraryId}`)
+ return res.status(404).send('Collection not found')
+ }
// Expand collection to get library items
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
if (!collectionExpanded) {
@@ -567,6 +582,10 @@ class PlaylistController {
Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)
return res.sendStatus(403)
}
+ if (!req.user.checkCanAccessLibrary(playlist.libraryId)) {
+ Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to access playlist ${playlist.id} in inaccessible library ${playlist.libraryId}`)
+ return res.status(404).send('Playlist not found')
+ }
req.playlist = playlist
}
diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index 1ebe1d110..5eaa2a64a 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -5,9 +5,10 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
+const cron = require('../libs/nodeCron')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
-const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
+const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
const { validateUrl } = require('../utils/index')
const htmlSanitizer = require('../utils/htmlSanitizer')
@@ -46,6 +47,11 @@ class PodcastController {
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
}
+ if (payload.media.autoDownloadSchedule && !cron.validate(payload.media.autoDownloadSchedule)) {
+ Logger.error(`[PodcastController] Invalid auto download schedule cron expression "${payload.media.autoDownloadSchedule}"`)
+ return res.status(400).send('Invalid auto download schedule cron expression')
+ }
+
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
@@ -58,8 +64,18 @@ class PodcastController {
return res.status(404).send('Folder not found')
}
+ if (typeof payload.path !== 'string' || !payload.path.trim()) {
+ return res.status(400).send('Invalid request body. "path" must be a non-empty string')
+ }
+
+ const libraryFolderPath = filePathToPOSIX(folder.path)
const podcastPath = filePathToPOSIX(payload.path)
+ if (!isSameOrSubPath(libraryFolderPath, podcastPath)) {
+ Logger.error(`[PodcastController] Create: Podcast path is outside library folder "${libraryFolderPath}": "${podcastPath}"`)
+ return res.status(400).send('Podcast path must be inside the selected library folder')
+ }
+
// Check if a library item with this podcast folder exists already
const existingLibraryItem =
(await Database.libraryItemModel.count({
@@ -83,7 +99,7 @@ class PodcastController {
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
- let relPath = payload.path.replace(folder.fullPath, '')
+ let relPath = podcastPath.replace(libraryFolderPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1)
let newLibraryItem = null
@@ -412,9 +428,26 @@ class PodcastController {
Logger.debug(`[PodcastController] Sanitized description from "${req.body[key]}" to "${sanitizedDescription}"`)
req.body[key] = sanitizedDescription
}
+ } else if (key === 'subtitle' && req.body[key]) {
+ const sanitizedSubtitle = htmlSanitizer.sanitize(req.body[key])
+ if (sanitizedSubtitle !== req.body[key]) {
+ Logger.debug(`[PodcastController] Sanitized subtitle from "${req.body[key]}" to "${sanitizedSubtitle}"`)
+ req.body[key] = sanitizedSubtitle
+ }
}
updatePayload[key] = req.body[key]
+ } else if (key === 'enclosure') {
+ const enclosure = req.body.enclosure
+ if (enclosure === null) {
+ updatePayload.enclosureURL = null
+ updatePayload.enclosureSize = null
+ updatePayload.enclosureType = null
+ } else if (typeof enclosure === 'object' && typeof enclosure.url === 'string') {
+ updatePayload.enclosureURL = enclosure.url
+ updatePayload.enclosureType = typeof enclosure.type === 'string' ? enclosure.type : null
+ updatePayload.enclosureSize = enclosure.length !== undefined && enclosure.length !== null ? enclosure.length : null
+ }
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
updatePayload[key] = req.body[key]
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js
index bb3382f71..ecd9a41c0 100644
--- a/server/controllers/SearchController.js
+++ b/server/controllers/SearchController.js
@@ -4,7 +4,29 @@ const BookFinder = require('../finders/BookFinder')
const PodcastFinder = require('../finders/PodcastFinder')
const AuthorFinder = require('../finders/AuthorFinder')
const Database = require('../Database')
-const { isValidASIN } = require('../utils')
+const { isValidASIN, getQueryParamAsString, ValidationError, NotFoundError } = require('../utils')
+
+// Provider name mappings for display purposes
+const providerMap = {
+ all: 'All',
+ best: 'Best',
+ google: 'Google Books',
+ itunes: 'iTunes',
+ openlibrary: 'Open Library',
+ fantlab: 'FantLab.ru',
+ audiobookcovers: 'AudiobookCovers.com',
+ audible: 'Audible.com',
+ 'audible.ca': 'Audible.ca',
+ 'audible.uk': 'Audible.co.uk',
+ 'audible.au': 'Audible.com.au',
+ 'audible.fr': 'Audible.fr',
+ 'audible.de': 'Audible.de',
+ 'audible.jp': 'Audible.co.jp',
+ 'audible.it': 'Audible.it',
+ 'audible.in': 'Audible.in',
+ 'audible.es': 'Audible.es',
+ audnexus: 'Audnexus'
+}
/**
* @typedef RequestUserObject
@@ -16,6 +38,44 @@ const { isValidASIN } = require('../utils')
class SearchController {
constructor() {}
+ /**
+ * Fetches a library item by ID
+ * @param {string} id - Library item ID
+ * @param {string} methodName - Name of the calling method for logging
+ * @returns {Promise}
+ */
+ static async fetchLibraryItem(id) {
+ const libraryItem = await Database.libraryItemModel.getExpandedById(id)
+ if (!libraryItem) {
+ throw new NotFoundError(`library item "${id}" not found`)
+ }
+ return libraryItem
+ }
+
+ /**
+ * Maps custom metadata providers to standardized format
+ * @param {Array} providers - Array of custom provider objects
+ * @returns {Array<{value: string, text: string}>}
+ */
+ static mapCustomProviders(providers) {
+ return providers.map((provider) => ({
+ value: provider.getSlug(),
+ text: provider.name
+ }))
+ }
+
+ /**
+ * Static helper method to format provider for client (for use in array methods)
+ * @param {string} providerValue - Provider identifier
+ * @returns {{value: string, text: string}}
+ */
+ static formatProvider(providerValue) {
+ return {
+ value: providerValue,
+ text: providerMap[providerValue] || providerValue
+ }
+ }
+
/**
* GET: /api/search/books
*
@@ -23,19 +83,25 @@ class SearchController {
* @param {Response} res
*/
async findBooks(req, res) {
- const id = req.query.id
- const libraryItem = await Database.libraryItemModel.getExpandedById(id)
- const provider = req.query.provider || 'google'
- const title = req.query.title || ''
- const author = req.query.author || ''
+ try {
+ const query = req.query
+ const provider = getQueryParamAsString(query, 'provider', 'google')
+ const title = getQueryParamAsString(query, 'title', '')
+ const author = getQueryParamAsString(query, 'author', '')
+ const id = getQueryParamAsString(query, 'id', undefined)
- if (typeof provider !== 'string' || typeof title !== 'string' || typeof author !== 'string') {
- Logger.error(`[SearchController] findBooks: Invalid request query params`)
- return res.status(400).send('Invalid request query params')
+ // Fetch library item
+ const libraryItem = id ? await SearchController.fetchLibraryItem(id) : null
+
+ const results = await BookFinder.search(libraryItem, provider, title, author)
+ res.json(results)
+ } catch (error) {
+ Logger.error(`[SearchController] findBooks: ${error.message}`)
+ if (error instanceof ValidationError || error instanceof NotFoundError) {
+ return res.status(error.status).json({ error: error.message })
+ }
+ return res.status(500).json({ error: 'Internal server error' })
}
-
- const results = await BookFinder.search(libraryItem, provider, title, author)
- res.json(results)
}
/**
@@ -45,20 +111,24 @@ class SearchController {
* @param {Response} res
*/
async findCovers(req, res) {
- const query = req.query
- const podcast = query.podcast == 1
+ try {
+ const query = req.query
+ const podcast = query.podcast === '1' || query.podcast === 1
+ const title = getQueryParamAsString(query, 'title', '', true)
+ const author = getQueryParamAsString(query, 'author', '')
+ const provider = getQueryParamAsString(query, 'provider', 'google')
- if (!query.title || typeof query.title !== 'string') {
- Logger.error(`[SearchController] findCovers: Invalid title sent in query`)
- return res.sendStatus(400)
+ let results = null
+ if (podcast) results = await PodcastFinder.findCovers(title)
+ else results = await BookFinder.findCovers(provider, title, author)
+ res.json({ results })
+ } catch (error) {
+ Logger.error(`[SearchController] findCovers: ${error.message}`)
+ if (error instanceof ValidationError) {
+ return res.status(error.status).json({ error: error.message })
+ }
+ return res.status(500).json({ error: 'Internal server error' })
}
-
- let results = null
- if (podcast) results = await PodcastFinder.findCovers(query.title)
- else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '')
- res.json({
- results
- })
}
/**
@@ -69,34 +139,42 @@ class SearchController {
* @param {Response} res
*/
async findPodcasts(req, res) {
- const term = req.query.term
- const country = req.query.country || 'us'
- if (!term) {
- Logger.error('[SearchController] Invalid request query param "term" is required')
- return res.status(400).send('Invalid request query param "term" is required')
- }
+ try {
+ const query = req.query
+ const term = getQueryParamAsString(query, 'term', '', true)
+ const country = getQueryParamAsString(query, 'country', 'us')
- const results = await PodcastFinder.search(term, {
- country
- })
- res.json(results)
+ const results = await PodcastFinder.search(term, { country })
+ res.json(results)
+ } catch (error) {
+ Logger.error(`[SearchController] findPodcasts: ${error.message}`)
+ if (error instanceof ValidationError) {
+ return res.status(error.status).json({ error: error.message })
+ }
+ return res.status(500).json({ error: 'Internal server error' })
+ }
}
/**
* GET: /api/search/authors
+ * Note: This endpoint is not currently used in the web client.
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findAuthor(req, res) {
- const query = req.query.q
- if (!query || typeof query !== 'string') {
- Logger.error(`[SearchController] findAuthor: Invalid query param`)
- return res.status(400).send('Invalid query param')
- }
+ try {
+ const query = getQueryParamAsString(req.query, 'q', '', true)
- const author = await AuthorFinder.findAuthorByName(query)
- res.json(author)
+ const author = await AuthorFinder.findAuthorByName(query)
+ res.json(author)
+ } catch (error) {
+ Logger.error(`[SearchController] findAuthor: ${error.message}`)
+ if (error instanceof ValidationError) {
+ return res.status(error.status).json({ error: error.message })
+ }
+ return res.status(500).json({ error: 'Internal server error' })
+ }
}
/**
@@ -106,16 +184,55 @@ class SearchController {
* @param {Response} res
*/
async findChapters(req, res) {
- const asin = req.query.asin
- if (!isValidASIN(asin.toUpperCase())) {
- return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
+ try {
+ const query = req.query
+ const asin = getQueryParamAsString(query, 'asin', '', true)
+ const region = getQueryParamAsString(query, 'region', 'us').toLowerCase()
+
+ if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid')
+
+ const chapterData = await BookFinder.findChapters(asin, region)
+ if (!chapterData) {
+ return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })
+ }
+ res.json(chapterData)
+ } catch (error) {
+ Logger.error(`[SearchController] findChapters: ${error.message}`)
+ if (error instanceof ValidationError) {
+ if (error.paramName === 'asin') {
+ return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
+ }
+ if (error.paramName === 'region') {
+ return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' })
+ }
+ }
+ return res.status(500).json({ error: 'Internal server error' })
}
- const region = (req.query.region || 'us').toLowerCase()
- const chapterData = await BookFinder.findChapters(asin, region)
- if (!chapterData) {
- return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })
+ }
+
+ /**
+ * GET: /api/search/providers
+ * Get all available metadata providers
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async getAllProviders(req, res) {
+ const customProviders = await Database.customMetadataProviderModel.findAll()
+
+ const customBookProviders = customProviders.filter((p) => p.mediaType === 'book')
+ const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast')
+
+ const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers')
+
+ // Build minimized payload with custom providers merged in
+ const providers = {
+ books: [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders)],
+ booksCovers: [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders), SearchController.formatProvider('all')],
+ podcasts: [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customPodcastProviders)]
}
- res.json(chapterData)
+
+ res.json({ providers })
}
}
module.exports = new SearchController()
diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js
index 872f635de..7cc8d35e2 100644
--- a/server/controllers/SessionController.js
+++ b/server/controllers/SessionController.js
@@ -339,9 +339,9 @@ class SessionController {
var playbackSession = this.playbackSessionManager.getSession(req.params.id)
if (!playbackSession) return res.sendStatus(404)
- if (playbackSession.userId !== req.user.id) {
- Logger.error(`[SessionController] User "${req.user.username}" attempting to access session belonging to another user "${req.params.id}"`)
- return res.sendStatus(404)
+ if (playbackSession.userId !== req.user.id && !req.user.isAdminOrUp) {
+ Logger.error(`[SessionController] Non-admin user "${req.user.username}" attempting to access session belonging to another user "${req.params.id}"`)
+ return res.sendStatus(403)
}
req.playbackSession = playbackSession
diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js
index 3e7ea1deb..73da84a8a 100644
--- a/server/controllers/ShareController.js
+++ b/server/controllers/ShareController.js
@@ -53,6 +53,10 @@ class ShareController {
if (playbackSession) {
if (mediaItemShare.id === playbackSession.mediaItemShareId) {
Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`)
+ // If ?t was provided, override the cached currentTime
+ if (startTime > 0 && startTime < playbackSession.duration) {
+ playbackSession.currentTime = startTime
+ }
mediaItemShare.playbackSession = playbackSession.toJSONForClient()
return res.json(mediaItemShare)
} else {
diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js
index 3ec10539e..0a476a9d6 100644
--- a/server/controllers/UserController.js
+++ b/server/controllers/UserController.js
@@ -253,6 +253,7 @@ class UserController {
// Updating password
if (updatePayload.password) {
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
+ shouldInvalidateJwtSessions = true
hasUpdates = true
}
@@ -331,14 +332,11 @@ class UserController {
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
}
- // Handle JWT session invalidation for username changes
+ // Handle JWT session invalidation for username/password changes
if (shouldInvalidateJwtSessions) {
- const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
- if (newAccessToken) {
- user.accessToken = newAccessToken
- // Refresh tokens are only returned for mobile clients
- // Mobile apps currently do not use this API endpoint so always set to null
- user.refreshToken = null
+ const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res)
+ if (newTokens) {
+ // Note: for admin users changing their own password they should use MeController.updatePassword instead. This endpoint does not return tokens
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
} else {
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
@@ -363,15 +361,16 @@ class UserController {
* @param {Response} res
*/
async delete(req, res) {
- if (req.params.id === 'root') {
- Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
- return res.sendStatus(400)
- }
+ const user = req.reqUser
+
if (req.user.id === req.params.id) {
Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
return res.sendStatus(400)
}
- const user = req.reqUser
+ if (user.isRoot) {
+ Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`)
+ return res.sendStatus(403)
+ }
// Todo: check if user is logged in and cancel streams
diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js
index a6a6b07e6..90fed951f 100644
--- a/server/finders/BookFinder.js
+++ b/server/finders/BookFinder.js
@@ -227,7 +227,7 @@ class BookFinder {
title = this.#removeAuthorFromTitle(title)
const titleTransformers = [
- [/([,:;_]| by ).*/g, ''], // Remove subtitle
+ [/(: |[,;_]| by ).*/g, ''], // Remove subtitle
[/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate
[/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
@@ -385,6 +385,11 @@ class BookFinder {
if (!title) return books
+ // Truncate excessively long inputs to prevent ReDoS attacks
+ const MAX_INPUT_LENGTH = 500
+ title = title.substring(0, MAX_INPUT_LENGTH)
+ author = author?.substring(0, MAX_INPUT_LENGTH) || author
+
const isTitleAsin = isValidASIN(title.toUpperCase())
let actualTitleQuery = title
@@ -402,7 +407,8 @@ class BookFinder {
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, ' - ')
+ // Use negated character classes to prevent ReDoS vulnerability (input length validated at entry point)
+ const cleanTitle = title.replace(/\[[^\]]*\]|\([^)]*\)|{[^}]*}|_/g, ' - ')
// Split title into hypen-separated parts
const titleParts = cleanTitle.split(/ - | -|- /)
for (const titlePart of titleParts) authorCandidates.add(titlePart)
@@ -422,7 +428,7 @@ class BookFinder {
}
}
- if (books.length) {
+ if (books.length && libraryItem) {
const isAudibleProvider = provider.startsWith('audible')
const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null
@@ -640,11 +646,11 @@ class BookFinder {
module.exports = new BookFinder()
function hasSubtitle(title) {
- return title.includes(':') || title.includes(' - ')
+ return title.includes(': ') || title.includes(' - ')
}
function stripSubtitle(title) {
- if (title.includes(':')) {
- return title.split(':')[0].trim()
+ if (title.includes(': ')) {
+ return title.split(': ')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}
@@ -668,7 +674,9 @@ function cleanTitleForCompares(title, keepSubtitle = false) {
let stripped = keepSubtitle ? title : stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
- let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
+ // Use negated character class to prevent ReDoS vulnerability (input length validated at entry point)
+ let cleaned = stripped.replace(/\([^)]*\)/g, '') // Remove parenthetical content
+ cleaned = cleaned.replace(/\s+/g, ' ').trim() // Clean up any resulting multiple spaces
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
diff --git a/server/finders/PodcastFinder.js b/server/finders/PodcastFinder.js
index abaf02ac6..40d6a5a00 100644
--- a/server/finders/PodcastFinder.js
+++ b/server/finders/PodcastFinder.js
@@ -7,9 +7,9 @@ class PodcastFinder {
}
/**
- *
- * @param {string} term
- * @param {{country:string}} options
+ *
+ * @param {string} term
+ * @param {{country:string}} options
* @returns {Promise}
*/
async search(term, options = {}) {
@@ -20,12 +20,16 @@ class PodcastFinder {
return results
}
+ /**
+ * @param {string} term
+ * @returns {Promise}
+ */
async findCovers(term) {
if (!term) return null
Logger.debug(`[iTunes] Searching for podcast covers with term "${term}"`)
- var results = await this.iTunesApi.searchPodcasts(term)
+ const results = await this.iTunesApi.searchPodcasts(term)
if (!results) return []
- return results.map(r => r.cover).filter(r => r)
+ return results.map((r) => r.cover).filter((r) => r)
}
}
-module.exports = new PodcastFinder()
\ No newline at end of file
+module.exports = new PodcastFinder()
diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js
index 2d8eece85..e34e4c055 100644
--- a/server/managers/ApiCacheManager.js
+++ b/server/managers/ApiCacheManager.js
@@ -5,6 +5,9 @@ const Database = require('../Database')
class ApiCacheManager {
defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: (item) => item.body.length + JSON.stringify(item.headers).length }
defaultTtlOptions = { ttl: 30 * 60 * 1000 }
+ highChurnModels = new Set(['session', 'mediaProgress', 'playbackSession', 'device'])
+ modelsInvalidatingPersonalized = new Set(['mediaProgress'])
+ modelsInvalidatingMe = new Set(['session', 'mediaProgress', 'playbackSession', 'device'])
constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) {
this.cache = cache
@@ -16,8 +19,47 @@ class ApiCacheManager {
hooks.forEach((hook) => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
}
+ getModelName(model) {
+ if (typeof model?.name === 'string') return model.name
+ if (typeof model?.model?.name === 'string') return model.model.name
+ if (typeof model?.constructor?.name === 'string' && model.constructor.name !== 'Object') return model.constructor.name
+ return 'unknown'
+ }
+
+ clearByUrlPattern(urlPattern) {
+ let removed = 0
+ for (const key of this.cache.keys()) {
+ try {
+ const parsed = JSON.parse(key)
+ if (typeof parsed?.url === 'string' && urlPattern.test(parsed.url)) {
+ if (this.cache.delete(key)) removed++
+ }
+ } catch {
+ if (this.cache.delete(key)) removed++
+ }
+ }
+ return removed
+ }
+
+ clearUserProgressSlices(modelName, hook) {
+ let removedPersonalized = 0
+ let removedRecentEpisodes = 0
+ if (this.modelsInvalidatingPersonalized.has(modelName)) {
+ removedPersonalized = this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/)
+ removedRecentEpisodes = this.clearByUrlPattern(/^\/libraries\/[^/]+\/recent-episodes/)
+ }
+ const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\/me(\/|\?|$)/) : 0
+ Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`)
+ }
+
clear(model, hook) {
- Logger.debug(`[ApiCacheManager] ${model.constructor.name}.${hook}: Clearing cache`)
+ const modelName = this.getModelName(model)
+ if (this.highChurnModels.has(modelName)) {
+ this.clearUserProgressSlices(modelName, hook)
+ return
+ }
+
+ Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: Clearing cache`)
this.cache.clear()
}
diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js
index 102521b71..a7b531e62 100644
--- a/server/managers/BackupManager.js
+++ b/server/managers/BackupManager.js
@@ -48,9 +48,14 @@ class BackupManager {
}
async init() {
- const backupsDirExists = await fs.pathExists(this.backupPath)
- if (!backupsDirExists) {
- await fs.ensureDir(this.backupPath)
+ try {
+ const backupsDirExists = await fs.pathExists(this.backupPath)
+ if (!backupsDirExists) {
+ await fs.ensureDir(this.backupPath)
+ }
+ } catch (error) {
+ Logger.error(`[BackupManager] Failed to ensure backup directory at "${this.backupPath}": ${error.message}`)
+ throw new Error(`[BackupManager] Failed to ensure backup directory at "${this.backupPath}"`, { cause: error })
}
await this.loadBackups()
@@ -121,13 +126,31 @@ class BackupManager {
} catch (error) {
// Not a valid zip file
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
+ await zip.close().catch(() => {})
+ await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
}
- if (!Object.keys(entries).includes('absdatabase.sqlite')) {
+ if (!entries['absdatabase.sqlite']) {
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
+ await zip.close().catch(() => {})
+ await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
}
+ const detailsEntry = entries['details']
+ if (!detailsEntry) {
+ Logger.error('[BackupManager] Invalid backup - missing details entry')
+ await zip.close().catch(() => {})
+ await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
+ return res.status(400).send('Invalid backup file - missing details entry')
+ }
+ if (detailsEntry.size > 1024 * 1024) {
+ Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
+ await zip.close().catch(() => {})
+ await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
+ return res.status(400).send('Invalid backup file - details entry too large')
+ }
+
const data = await zip.entryData('details')
const details = data.toString('utf8').split('\n')
@@ -135,9 +158,13 @@ class BackupManager {
if (!backup.serverVersion) {
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
+ await zip.close().catch(() => {})
+ await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')
}
+ await zip.close().catch(() => {})
+
backup.fileSize = await getFileSize(backup.fullPath)
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
@@ -252,9 +279,24 @@ class BackupManager {
let data = null
try {
zip = new StreamZip.async({ file: fullFilePath })
+ const entries = await zip.entries()
+
+ const detailsEntry = entries['details']
+ if (!detailsEntry) {
+ Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`)
+ await zip.close().catch(() => {})
+ continue
+ }
+ if (detailsEntry.size > 1024 * 1024) {
+ Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`)
+ await zip.close().catch(() => {})
+ continue
+ }
+
data = await zip.entryData('details')
} catch (error) {
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
+ if (zip) await zip.close().catch(() => {})
continue
}
diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js
index b44b65de3..38c0415e9 100644
--- a/server/managers/CacheManager.js
+++ b/server/managers/CacheManager.js
@@ -24,10 +24,15 @@ class CacheManager {
this.ImageCachePath = Path.join(this.CachePath, 'images')
this.ItemCachePath = Path.join(this.CachePath, 'items')
- await fs.ensureDir(this.CachePath)
- await fs.ensureDir(this.CoverCachePath)
- await fs.ensureDir(this.ImageCachePath)
- await fs.ensureDir(this.ItemCachePath)
+ try {
+ await fs.ensureDir(this.CachePath)
+ await fs.ensureDir(this.CoverCachePath)
+ await fs.ensureDir(this.ImageCachePath)
+ await fs.ensureDir(this.ItemCachePath)
+ } catch (error) {
+ Logger.error(`[CacheManager] Failed to create cache directories at "${this.CachePath}": ${error.message}`)
+ throw new Error(`[CacheManager] Failed to create cache directories at "${this.CachePath}"`, { cause: error })
+ }
}
async handleCoverCache(res, libraryItemId, options = {}) {
diff --git a/server/managers/CoverSearchManager.js b/server/managers/CoverSearchManager.js
index ddcaa23db..193176766 100644
--- a/server/managers/CoverSearchManager.js
+++ b/server/managers/CoverSearchManager.js
@@ -224,6 +224,9 @@ class CoverSearchManager {
if (!Array.isArray(results)) return covers
results.forEach((result) => {
+ if (typeof result === 'string') {
+ covers.push(result)
+ }
if (result.covers && Array.isArray(result.covers)) {
covers.push(...result.covers)
}
diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js
index d3e652129..7138d26a8 100644
--- a/server/managers/CronManager.js
+++ b/server/managers/CronManager.js
@@ -153,6 +153,11 @@ class CronManager {
startPodcastCron(expression, libraryItemIds) {
try {
+ if (!cron.validate(expression)) {
+ Logger.error(`[CronManager] Invalid auto download schedule cron expression "${expression}" - not starting podcast episode check cron`)
+ return
+ }
+
Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`)
const task = cron.schedule(expression, () => {
if (this.podcastCronExpressionsExecuting.includes(expression)) {
@@ -167,7 +172,7 @@ class CronManager {
task
})
} catch (error) {
- Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
+ Logger.error(`[PodcastManager] Failed to schedule podcast cron ${expression}`, error)
}
}
diff --git a/server/managers/LogManager.js b/server/managers/LogManager.js
index 731dfe70a..07bb7d401 100644
--- a/server/managers/LogManager.js
+++ b/server/managers/LogManager.js
@@ -37,8 +37,13 @@ class LogManager {
}
async ensureLogDirs() {
- await fs.ensureDir(this.DailyLogPath)
- await fs.ensureDir(this.ScanLogPath)
+ try {
+ await fs.ensureDir(this.DailyLogPath)
+ await fs.ensureDir(this.ScanLogPath)
+ } catch (error) {
+ console.error(`[LogManager] Failed to create log directories at "${this.DailyLogPath}": ${error.message}`)
+ throw new Error(`[LogManager] Failed to create log directories at "${this.DailyLogPath}"`, { cause: error })
+ }
}
/**
@@ -102,20 +107,20 @@ class LogManager {
}
/**
- *
- * @param {string} filename
+ *
+ * @param {string} filename
*/
async removeLogFile(filename) {
const fullPath = Path.join(this.DailyLogPath, filename)
const exists = await fs.pathExists(fullPath)
if (!exists) {
Logger.error(TAG, 'Invalid log dne ' + fullPath)
- this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename)
+ this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename)
} else {
try {
await fs.unlink(fullPath)
Logger.info(TAG, 'Removed daily log: ' + filename)
- this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename)
+ this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename)
} catch (error) {
Logger.error(TAG, 'Failed to unlink log file ' + fullPath)
}
@@ -123,8 +128,8 @@ class LogManager {
}
/**
- *
- * @param {LogObject} logObj
+ *
+ * @param {LogObject} logObj
*/
async logToFile(logObj) {
// Fatal crashes get logged to a separate file
@@ -152,8 +157,8 @@ class LogManager {
}
/**
- *
- * @param {LogObject} logObj
+ *
+ * @param {LogObject} logObj
*/
async logCrashToFile(logObj) {
const line = JSON.stringify(logObj) + '\n'
@@ -161,18 +166,18 @@ class LogManager {
const logsDir = Path.join(global.MetadataPath, 'logs')
await fs.ensureDir(logsDir)
const crashLogPath = Path.join(logsDir, 'crash_logs.txt')
- return fs.writeFile(crashLogPath, line, { flag: "a+" }).catch((error) => {
+ return fs.writeFile(crashLogPath, line, { flag: 'a+' }).catch((error) => {
console.log('[LogManager] Appended crash log', error)
})
}
/**
* Most recent 5000 daily logs
- *
+ *
* @returns {string}
*/
getMostRecentCurrentDailyLogs() {
return this.currentDailyLog?.logs.slice(-5000) || ''
}
}
-module.exports = LogManager
\ No newline at end of file
+module.exports = LogManager
diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js
index 8635def10..e302038ff 100644
--- a/server/managers/MigrationManager.js
+++ b/server/managers/MigrationManager.js
@@ -38,7 +38,12 @@ class MigrationManager {
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)
this.migrationsDir = path.join(this.configPath, 'migrations')
- await fs.ensureDir(this.migrationsDir)
+ try {
+ await fs.ensureDir(this.migrationsDir)
+ } catch (error) {
+ Logger.error(`[MigrationManager] Failed to create migrations directory at "${this.migrationsDir}": ${error.message}`)
+ throw new Error(`[MigrationManager] Failed to create migrations directory at "${this.migrationsDir}"`, { cause: error })
+ }
this.serverVersion = this.extractVersionFromTag(serverVersion)
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js
index fb12fedaa..1e95727f1 100644
--- a/server/managers/PlaybackSessionManager.js
+++ b/server/managers/PlaybackSessionManager.js
@@ -459,7 +459,12 @@ class PlaybackSessionManager {
* Remove all stream folders in `/metadata/streams`
*/
async removeOrphanStreams() {
- await fs.ensureDir(this.StreamsPath)
+ try {
+ await fs.ensureDir(this.StreamsPath)
+ } catch (error) {
+ Logger.error(`[PlaybackSessionManager] Failed to create streams directory at "${this.StreamsPath}": ${error.message}`)
+ throw new Error(`[PlaybackSessionManager] Failed to create streams directory at "${this.StreamsPath}"`, { cause: error })
+ }
try {
const streamsInPath = await fs.readdir(this.StreamsPath)
for (const streamId of streamsInPath) {
diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js
index c4681bdc2..a066a0d32 100644
--- a/server/managers/RssFeedManager.js
+++ b/server/managers/RssFeedManager.js
@@ -2,6 +2,7 @@ const { Request, Response } = require('express')
const Path = require('path')
const Logger = require('../Logger')
+const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@@ -216,6 +217,11 @@ class RssFeedManager {
res.sendStatus(404)
return
}
+ // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
+ const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath))
+ if (audioMimeType) {
+ res.setHeader('Content-Type', audioMimeType)
+ }
res.sendFile(episodePath)
}
diff --git a/server/migrations/v2.33.0-add-discover-query-indexes.js b/server/migrations/v2.33.0-add-discover-query-indexes.js
new file mode 100644
index 000000000..ebd92bbaa
--- /dev/null
+++ b/server/migrations/v2.33.0-add-discover-query-indexes.js
@@ -0,0 +1,74 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface
+ * @property {import('../Logger')} logger
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context
+ */
+
+const migrationVersion = '2.33.0'
+const migrationName = `${migrationVersion}-add-discover-query-indexes`
+const loggerPrefix = `[${migrationVersion} migration]`
+
+const indexes = [
+ {
+ table: 'mediaProgresses',
+ name: 'media_progresses_user_item_finished_time',
+ fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime']
+ },
+ {
+ table: 'bookSeries',
+ name: 'book_series_series_book',
+ fields: ['seriesId', 'bookId']
+ }
+]
+
+async function up({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ for (const index of indexes) {
+ await addIndexIfMissing(queryInterface, logger, index)
+ }
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+async function down({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ for (const index of indexes) {
+ await removeIndexIfExists(queryInterface, logger, index)
+ }
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+async function addIndexIfMissing(queryInterface, logger, index) {
+ const existing = await queryInterface.showIndex(index.table)
+ if (existing.some((i) => i.name === index.name)) {
+ logger.info(`${loggerPrefix} index ${index.name} already exists on ${index.table}`)
+ return
+ }
+
+ logger.info(`${loggerPrefix} adding index ${index.name} on ${index.table}(${index.fields.join(', ')})`)
+ await queryInterface.addIndex(index.table, {
+ name: index.name,
+ fields: index.fields
+ })
+ logger.info(`${loggerPrefix} added index ${index.name}`)
+}
+
+async function removeIndexIfExists(queryInterface, logger, index) {
+ const existing = await queryInterface.showIndex(index.table)
+ if (!existing.some((i) => i.name === index.name)) {
+ logger.info(`${loggerPrefix} index ${index.name} does not exist on ${index.table}`)
+ return
+ }
+
+ logger.info(`${loggerPrefix} removing index ${index.name}`)
+ await queryInterface.removeIndex(index.table, index.name)
+ logger.info(`${loggerPrefix} removed index ${index.name}`)
+}
+
+module.exports = { up, down }
diff --git a/server/migrations/v2.35.0-add-last-refresh-token.js b/server/migrations/v2.35.0-add-last-refresh-token.js
new file mode 100644
index 000000000..0ad190e9a
--- /dev/null
+++ b/server/migrations/v2.35.0-add-last-refresh-token.js
@@ -0,0 +1,84 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
+ * @property {import('../Logger')} logger - a Logger object.
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context - an object containing the migration context.
+ */
+
+const migrationVersion = '2.35.0'
+const migrationName = `${migrationVersion}-add-last-refresh-token`
+const loggerPrefix = `[${migrationVersion} migration]`
+
+/**
+ * This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function up({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ if (await queryInterface.tableExists('sessions')) {
+ const tableDescription = await queryInterface.describeTable('sessions')
+
+ if (!tableDescription.lastRefreshToken) {
+ logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`)
+ await queryInterface.addColumn('sessions', 'lastRefreshToken', {
+ type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
+ allowNull: true
+ })
+ } else {
+ logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`)
+ }
+
+ if (!tableDescription.lastRefreshTokenExpiresAt) {
+ logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`)
+ await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', {
+ type: queryInterface.sequelize.Sequelize.DataTypes.DATE,
+ allowNull: true
+ })
+ } else {
+ logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`)
+ }
+ } else {
+ logger.info(`${loggerPrefix} sessions table does not exist`)
+ }
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+/**
+ * This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function down({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ if (await queryInterface.tableExists('sessions')) {
+ const tableDescription = await queryInterface.describeTable('sessions')
+
+ if (tableDescription.lastRefreshToken) {
+ logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`)
+ await queryInterface.removeColumn('sessions', 'lastRefreshToken')
+ } else {
+ logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`)
+ }
+
+ if (tableDescription.lastRefreshTokenExpiresAt) {
+ logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`)
+ await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt')
+ } else {
+ logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`)
+ }
+ } else {
+ logger.info(`${loggerPrefix} sessions table does not exist`)
+ }
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+module.exports = { up, down }
diff --git a/server/models/Author.js b/server/models/Author.js
index 287b66976..65561e211 100644
--- a/server/models/Author.js
+++ b/server/models/Author.js
@@ -111,16 +111,17 @@ class Author extends Model {
*
* @param {string} name
* @param {string} libraryId
- * @returns {Promise}
+ * @returns {Promise<{ author: Author, created: boolean }>}
*/
static async findOrCreateByNameAndLibrary(name, libraryId) {
const author = await this.getByNameAndLibrary(name, libraryId)
- if (author) return author
- return this.create({
+ if (author) return { author, created: false }
+ const newAuthor = await this.create({
name,
lastFirst: this.getLastFirst(name),
libraryId
})
+ return { author: newAuthor, created: true }
}
/**
diff --git a/server/models/Book.js b/server/models/Book.js
index 96371f3a2..7e1a2e30c 100644
--- a/server/models/Book.js
+++ b/server/models/Book.js
@@ -4,6 +4,7 @@ const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString')
const htmlSanitizer = require('../utils/htmlSanitizer')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
+const SocketAuthority = require('../SocketAuthority')
/**
* @typedef EBookFileObject
@@ -470,13 +471,23 @@ class Book extends Model {
for (const author of authorsRemoved) {
await bookAuthorModel.removeByIds(author.id, this.id)
+ const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
+ if (numBooks > 0) {
+ SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
+ }
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
this.authors = this.authors.filter((au) => au.id !== author.id)
}
const authorsAdded = []
for (const authorName of newAuthorNames) {
- const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
+ const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
+ if (created) {
+ SocketAuthority.emitter('author_added', author.toOldJSON())
+ } else {
+ const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
+ SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
+ }
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
this.authors.push(author)
authorsAdded.push(author)
@@ -636,6 +647,11 @@ class Book extends Model {
}
}
+ /**
+ * Minified book JSON for list/shelf endpoints.
+ * `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
+ * with the same value semantics. Only additive changes to expanded; never remove or rename keys.
+ */
toOldJSONMinified() {
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
@@ -658,6 +674,12 @@ class Book extends Model {
}
}
+ /**
+ * Expanded book JSON for item detail and socket events.
+ * Must be a strict superset of `toOldJSONMinified()` â built by spreading minified, then adding expanded-only fields.
+ *
+ * @param {string} libraryItemId
+ */
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
@@ -670,16 +692,12 @@ class Book extends Model {
}
return {
- id: this.id,
- libraryItemId: libraryItemId,
+ ...this.toOldJSONMinified(),
+ libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
- coverPath: this.coverPath,
- tags: [...(this.tags || [])],
audioFiles: structuredClone(this.audioFiles),
chapters: structuredClone(this.chapters),
ebookFile: structuredClone(this.ebookFile),
- duration: this.duration,
- size: this.size,
tracks: this.getTracklist(libraryItemId)
}
}
diff --git a/server/models/BookAuthor.js b/server/models/BookAuthor.js
index d7d65728e..f2cbf60f0 100644
--- a/server/models/BookAuthor.js
+++ b/server/models/BookAuthor.js
@@ -1,4 +1,4 @@
-const { DataTypes, Model } = require('sequelize')
+const { DataTypes, Model, fn, col } = require('sequelize')
class BookAuthor extends Model {
constructor(values, options) {
@@ -37,6 +37,32 @@ class BookAuthor extends Model {
})
}
+ /**
+ * Get number of books for each author
+ *
+ * @param {string[]} authorIds
+ * @returns {Promise>}
+ */
+ static async getCountsForAuthors(authorIds) {
+ if (!authorIds.length) return {}
+
+ const rows = await this.findAll({
+ attributes: ['authorId', [fn('COUNT', col('id')), 'count']],
+ where: {
+ authorId: authorIds
+ },
+ group: ['authorId'],
+ raw: true
+ })
+
+ /** @type {Record} */
+ const counts = {}
+ for (const row of rows) {
+ counts[row.authorId] = Number(row.count)
+ }
+ return counts
+ }
+
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
diff --git a/server/models/BookSeries.js b/server/models/BookSeries.js
index 31eccb9fa..e71f28a8e 100644
--- a/server/models/BookSeries.js
+++ b/server/models/BookSeries.js
@@ -48,6 +48,10 @@ class BookSeries extends Model {
{
name: 'bookSeries_seriesId',
fields: ['seriesId']
+ },
+ {
+ name: 'book_series_series_book',
+ fields: ['seriesId', 'bookId']
}
]
}
diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js
index 16a521615..c03667efc 100644
--- a/server/models/LibraryItem.js
+++ b/server/models/LibraryItem.js
@@ -340,6 +340,15 @@ class LibraryItem extends Model {
const shelves = []
+ const timed = async (loader) => {
+ const start = Date.now()
+ const payload = await loader()
+ return {
+ payload,
+ elapsedSeconds: ((Date.now() - start) / 1000).toFixed(2)
+ }
+ }
+
// "Continue Listening" shelf
const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)
if (itemsInProgressPayload.items.length) {
@@ -371,11 +380,18 @@ class LibraryItem extends Model {
}
Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
- let start = Date.now()
if (library.isBook) {
- start = Date.now()
+ const [continueSeriesResult, mostRecentResult, seriesMostRecentResult, discoverResult, mediaFinishedResult, newestAuthorsResult] = await Promise.all([
+ timed(() => libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)),
+ timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),
+ timed(() => libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)),
+ timed(() => libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)),
+ timed(() => libraryFilters.getMediaFinished(library, user, include, limit)),
+ timed(() => libraryFilters.getNewestAuthors(library, user, limit))
+ ])
+
+ const continueSeriesPayload = continueSeriesResult.payload
// "Continue Series" shelf
- const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)
if (continueSeriesPayload.libraryItems.length) {
shelves.push({
id: 'continue-series',
@@ -386,42 +402,24 @@ class LibraryItem extends Model {
total: continueSeriesPayload.count
})
}
- Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
- } else if (library.isPodcast) {
- // "Newest Episodes" shelf
- const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit)
- if (newestEpisodesPayload.libraryItems.length) {
+ Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${continueSeriesResult.elapsedSeconds}s`)
+
+ const mostRecentPayload = mostRecentResult.payload
+ // "Recently Added" shelf
+ if (mostRecentPayload.libraryItems.length) {
shelves.push({
- id: 'newest-episodes',
- label: 'Newest Episodes',
- labelStringKey: 'LabelNewestEpisodes',
- type: 'episode',
- entities: newestEpisodesPayload.libraryItems,
- total: newestEpisodesPayload.count
+ id: 'recently-added',
+ label: 'Recently Added',
+ labelStringKey: 'LabelRecentlyAdded',
+ type: library.mediaType,
+ entities: mostRecentPayload.libraryItems,
+ total: mostRecentPayload.count
})
}
- Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
- }
+ Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${mostRecentResult.elapsedSeconds}s`)
- start = Date.now()
- // "Recently Added" shelf
- const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)
- if (mostRecentPayload.libraryItems.length) {
- shelves.push({
- id: 'recently-added',
- label: 'Recently Added',
- labelStringKey: 'LabelRecentlyAdded',
- type: library.mediaType,
- entities: mostRecentPayload.libraryItems,
- total: mostRecentPayload.count
- })
- }
- Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
-
- if (library.isBook) {
- start = Date.now()
+ const seriesMostRecentPayload = seriesMostRecentResult.payload
// "Recent Series" shelf
- const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)
if (seriesMostRecentPayload.series.length) {
shelves.push({
id: 'recent-series',
@@ -432,11 +430,10 @@ class LibraryItem extends Model {
total: seriesMostRecentPayload.count
})
}
- Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
+ Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${seriesMostRecentResult.elapsedSeconds}s`)
- start = Date.now()
+ const discoverLibraryItemsPayload = discoverResult.payload
// "Discover" shelf
- const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)
if (discoverLibraryItemsPayload.libraryItems.length) {
shelves.push({
id: 'discover',
@@ -447,45 +444,41 @@ class LibraryItem extends Model {
total: discoverLibraryItemsPayload.count
})
}
- Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
- }
+ Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${discoverResult.elapsedSeconds}s`)
- start = Date.now()
- // "Listen Again" shelf
- const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit)
- if (mediaFinishedPayload.items.length) {
- const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
- const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
+ const mediaFinishedPayload = mediaFinishedResult.payload
+ // "Listen Again" shelf
+ if (mediaFinishedPayload.items.length) {
+ const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
+ const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
- if (audioItemsInProgress.length) {
- shelves.push({
- id: 'listen-again',
- label: 'Listen Again',
- labelStringKey: 'LabelListenAgain',
- type: library.isPodcast ? 'episode' : 'book',
- entities: audioItemsInProgress,
- total: mediaFinishedPayload.count
- })
+ if (audioItemsInProgress.length) {
+ shelves.push({
+ id: 'listen-again',
+ label: 'Listen Again',
+ labelStringKey: 'LabelListenAgain',
+ type: library.isPodcast ? 'episode' : 'book',
+ entities: audioItemsInProgress,
+ total: mediaFinishedPayload.count
+ })
+ }
+
+ if (ebookOnlyItemsInProgress.length) {
+ // "Read Again" shelf
+ shelves.push({
+ id: 'read-again',
+ label: 'Read Again',
+ labelStringKey: 'LabelReadAgain',
+ type: 'book',
+ entities: ebookOnlyItemsInProgress,
+ total: mediaFinishedPayload.count
+ })
+ }
}
+ Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${mediaFinishedResult.elapsedSeconds}s`)
- // "Read Again" shelf
- if (ebookOnlyItemsInProgress.length) {
- shelves.push({
- id: 'read-again',
- label: 'Read Again',
- labelStringKey: 'LabelReadAgain',
- type: 'book',
- entities: ebookOnlyItemsInProgress,
- total: mediaFinishedPayload.count
- })
- }
- }
- Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
-
- if (library.isBook) {
- start = Date.now()
+ const newestAuthorsPayload = newestAuthorsResult.payload
// "Newest Authors" shelf
- const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit)
if (newestAuthorsPayload.authors.length) {
shelves.push({
id: 'newest-authors',
@@ -496,7 +489,72 @@ class LibraryItem extends Model {
total: newestAuthorsPayload.count
})
}
- Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
+ Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${newestAuthorsResult.elapsedSeconds}s`)
+ } else if (library.isPodcast) {
+ const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([
+ timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)),
+ timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),
+ timed(() => libraryFilters.getMediaFinished(library, user, include, limit))
+ ])
+
+ const newestEpisodesPayload = newestEpisodesResult.payload
+ // "Newest Episodes" shelf
+ if (newestEpisodesPayload.libraryItems.length) {
+ shelves.push({
+ id: 'newest-episodes',
+ label: 'Newest Episodes',
+ labelStringKey: 'LabelNewestEpisodes',
+ type: 'episode',
+ entities: newestEpisodesPayload.libraryItems,
+ total: newestEpisodesPayload.count
+ })
+ }
+ Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${newestEpisodesResult.elapsedSeconds}s`)
+
+ const mostRecentPayload = mostRecentResult.payload
+ // "Recently Added" shelf
+ if (mostRecentPayload.libraryItems.length) {
+ shelves.push({
+ id: 'recently-added',
+ label: 'Recently Added',
+ labelStringKey: 'LabelRecentlyAdded',
+ type: library.mediaType,
+ entities: mostRecentPayload.libraryItems,
+ total: mostRecentPayload.count
+ })
+ }
+ Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${mostRecentResult.elapsedSeconds}s`)
+
+ const mediaFinishedPayload = mediaFinishedResult.payload
+ // "Listen Again" shelf
+ if (mediaFinishedPayload.items.length) {
+ const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
+ const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
+
+ if (audioItemsInProgress.length) {
+ shelves.push({
+ id: 'listen-again',
+ label: 'Listen Again',
+ labelStringKey: 'LabelListenAgain',
+ type: 'episode',
+ entities: audioItemsInProgress,
+ total: mediaFinishedPayload.count
+ })
+ }
+
+ if (ebookOnlyItemsInProgress.length) {
+ // "Read Again" shelf
+ shelves.push({
+ id: 'read-again',
+ label: 'Read Again',
+ labelStringKey: 'LabelReadAgain',
+ type: 'book',
+ entities: ebookOnlyItemsInProgress,
+ total: mediaFinishedPayload.count
+ })
+ }
+ }
+ Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${mediaFinishedResult.elapsedSeconds}s`)
}
Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
@@ -946,6 +1004,11 @@ class LibraryItem extends Model {
}
}
+ /**
+ * Minified library item JSON for list/shelf endpoints.
+ * `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
+ * with the same value semantics. Only additive changes to expanded; never remove or rename keys.
+ */
toOldJSONMinified() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
@@ -974,30 +1037,18 @@ class LibraryItem extends Model {
}
}
+ /**
+ * Expanded library item JSON for item detail and socket events.
+ * Must be a strict superset of `toOldJSONMinified()` â built by spreading minified, then adding expanded-only fields.
+ */
toOldJSONExpanded() {
return {
- id: this.id,
- ino: this.ino,
- oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
- libraryId: this.libraryId,
- folderId: this.libraryFolderId,
- path: this.path,
- relPath: this.relPath,
- isFile: this.isFile,
- mtimeMs: this.mtime?.valueOf(),
- ctimeMs: this.ctime?.valueOf(),
- birthtimeMs: this.birthtime?.valueOf(),
- addedAt: this.createdAt.valueOf(),
- updatedAt: this.updatedAt.valueOf(),
+ ...this.toOldJSONMinified(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
- isMissing: !!this.isMissing,
- isInvalid: !!this.isInvalid,
- mediaType: this.mediaType,
media: this.media.toOldJSONExpanded(this.id),
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
- libraryFiles: this.getLibraryFilesJson(),
- size: this.size
+ libraryFiles: this.getLibraryFilesJson()
}
}
}
diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js
index 0ebe2f598..9c0269a9e 100644
--- a/server/models/MediaProgress.js
+++ b/server/models/MediaProgress.js
@@ -80,6 +80,10 @@ class MediaProgress extends Model {
indexes: [
{
fields: ['updatedAt']
+ },
+ {
+ name: 'media_progresses_user_item_finished_time',
+ fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime']
}
]
}
diff --git a/server/models/Podcast.js b/server/models/Podcast.js
index a96e1dd02..9c222e48a 100644
--- a/server/models/Podcast.js
+++ b/server/models/Podcast.js
@@ -78,6 +78,7 @@ class Podcast extends Model {
*/
static async createFromRequest(payload, transaction) {
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
+ // cron expression validated in controller
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
@@ -89,6 +90,9 @@ class Podcast extends Model {
}
})
+ const rawDescription = typeof payload.metadata.description === 'string' ? payload.metadata.description : null
+ const description = rawDescription ? htmlSanitizer.sanitize(rawDescription) : null
+
return this.create(
{
title,
@@ -97,7 +101,7 @@ class Podcast extends Model {
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
- description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
+ description,
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
@@ -270,6 +274,7 @@ class Podcast extends Model {
hasUpdates = true
}
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
+ // cron expression validated in controller
this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true
}
@@ -446,6 +451,11 @@ class Podcast extends Model {
}
}
+ /**
+ * Minified podcast JSON for list/shelf endpoints.
+ * `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
+ * with the same value semantics. Only additive changes to expanded; never remove or rename keys.
+ */
toOldJSONMinified() {
return {
id: this.id,
@@ -463,6 +473,12 @@ class Podcast extends Model {
}
}
+ /**
+ * Expanded podcast JSON for item detail and socket events.
+ * Must be a strict superset of `toOldJSONMinified()` â built by spreading minified, then adding expanded-only fields.
+ *
+ * @param {string} libraryItemId
+ */
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
@@ -472,18 +488,9 @@ class Podcast extends Model {
}
return {
- id: this.id,
- libraryItemId: libraryItemId,
- metadata: this.oldMetadataToJSONExpanded(),
- coverPath: this.coverPath,
- tags: [...(this.tags || [])],
- episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
- autoDownloadEpisodes: this.autoDownloadEpisodes,
- autoDownloadSchedule: this.autoDownloadSchedule,
- lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
- maxEpisodesToKeep: this.maxEpisodesToKeep,
- maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
- size: this.size
+ ...this.toOldJSONMinified(),
+ libraryItemId,
+ episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId))
}
}
}
diff --git a/server/models/Session.js b/server/models/Session.js
index fe9dd5425..3b85bd46a 100644
--- a/server/models/Session.js
+++ b/server/models/Session.js
@@ -18,6 +18,10 @@ class Session extends Model {
this.userId
/** @type {Date} */
this.expiresAt
+ /** @type {string} */
+ this.lastRefreshToken
+ /** @type {Date} */
+ this.lastRefreshTokenExpiresAt
// Expanded properties
@@ -66,6 +70,14 @@ class Session extends Model {
expiresAt: {
type: DataTypes.DATE,
allowNull: false
+ },
+ lastRefreshToken: {
+ type: DataTypes.STRING,
+ allowNull: true
+ },
+ lastRefreshTokenExpiresAt: {
+ type: DataTypes.DATE,
+ allowNull: true
}
},
{
diff --git a/server/models/User.js b/server/models/User.js
index 36b2eca98..0b9d49438 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -352,11 +352,7 @@ class User extends Model {
if (cachedUser) return cachedUser
const user = await this.findOne({
- where: {
- username: {
- [sequelize.Op.like]: username
- }
- },
+ where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()),
include: this.sequelize.models.mediaProgress
})
@@ -377,11 +373,7 @@ class User extends Model {
if (cachedUser) return cachedUser
const user = await this.findOne({
- where: {
- email: {
- [sequelize.Op.like]: email
- }
- },
+ where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()),
include: this.sequelize.models.mediaProgress
})
@@ -538,7 +530,14 @@ class User extends Model {
},
{
sequelize,
- modelName: 'user'
+ modelName: 'user',
+ hooks: {
+ beforeDestroy(user) {
+ if (user.type === 'root') {
+ throw new Error('Root user cannot be deleted')
+ }
+ }
+ }
}
)
}
@@ -782,7 +781,14 @@ class User extends Model {
error: 'Library item not found',
statusCode: 404
}
+ } else if (libraryItem.mediaType !== 'book') {
+ Logger.error(`[User] createUpdateMediaProgress: library item ${progressPayload.libraryItemId} is not a book`)
+ return {
+ error: 'Library item is not a book',
+ statusCode: 400
+ }
}
+
mediaItemId = libraryItem.media.id
mediaProgress = libraryItem.media.mediaProgresses?.[0]
}
diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js
index ceff6c32e..68237f0fb 100644
--- a/server/objects/DeviceInfo.js
+++ b/server/objects/DeviceInfo.js
@@ -1,6 +1,10 @@
-const uuidv4 = require("uuid").v4
+const uuidv4 = require('uuid').v4
+const { stripAllTags } = require('../utils/htmlSanitizer')
class DeviceInfo {
+ /** @type {string[]} Fields to sanitize when loading from stored data */
+ static stringFields = ['deviceId', 'clientVersion', 'manufacturer', 'model', 'sdkVersion', 'clientName', 'deviceName']
+
constructor(deviceInfo = null) {
this.id = null
this.userId = null
@@ -31,7 +35,7 @@ class DeviceInfo {
construct(deviceInfo) {
for (const key in deviceInfo) {
if (deviceInfo[key] !== undefined && this[key] !== undefined) {
- this[key] = deviceInfo[key]
+ this[key] = DeviceInfo.stringFields.includes(key) ? stripAllTags(deviceInfo[key]) : deviceInfo[key]
}
}
}
@@ -63,7 +67,8 @@ class DeviceInfo {
}
get deviceDescription() {
- if (this.model) { // Set from mobile apps
+ if (this.model) {
+ // Set from mobile apps
if (this.sdkVersion) return `${this.model} SDK ${this.sdkVersion} / v${this.clientVersion}`
return `${this.model} / v${this.clientVersion}`
}
@@ -72,18 +77,7 @@ class DeviceInfo {
// When client doesn't send a device id
getTempDeviceId() {
- const keys = [
- this.userId,
- this.browserName,
- this.browserVersion,
- this.osName,
- this.osVersion,
- this.clientVersion,
- this.manufacturer,
- this.model,
- this.sdkVersion,
- this.ipAddress
- ].map(k => k || '')
+ const keys = [this.userId, this.browserName, this.browserVersion, this.osName, this.osVersion, this.clientVersion, this.manufacturer, this.model, this.sdkVersion, this.ipAddress].map((k) => k || '')
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
}
@@ -99,12 +93,17 @@ class DeviceInfo {
this.osVersion = ua?.os.version || null
this.deviceType = ua?.device.type || null
- this.clientVersion = clientDeviceInfo?.clientVersion || serverVersion
- this.manufacturer = clientDeviceInfo?.manufacturer || null
- this.model = clientDeviceInfo?.model || null
- this.sdkVersion = clientDeviceInfo?.sdkVersion || null
+ this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
+ this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
+ this.model = stripAllTags(clientDeviceInfo?.model) || null
- this.clientName = clientDeviceInfo?.clientName || null
+ if (typeof clientDeviceInfo?.sdkVersion === 'number') {
+ this.sdkVersion = clientDeviceInfo.sdkVersion.toString()
+ } else {
+ this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
+ }
+
+ this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null
if (this.sdkVersion) {
if (!this.clientName) this.clientName = 'Abs Android'
this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}`
@@ -149,4 +148,4 @@ class DeviceInfo {
return hasUpdates
}
}
-module.exports = DeviceInfo
\ No newline at end of file
+module.exports = DeviceInfo
diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js
index ba031b665..ace0256e8 100644
--- a/server/objects/PlaybackSession.js
+++ b/server/objects/PlaybackSession.js
@@ -110,7 +110,8 @@ class PlaybackSession {
startedAt: this.startedAt,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
- libraryItem: libraryItem?.toOldJSONExpanded() || null
+ libraryItem: libraryItem?.toOldJSONExpanded() || null,
+ coverAspectRatio: this.coverAspectRatio !== null ? this.coverAspectRatio : undefined // Used for share sessions
}
}
diff --git a/server/objects/Stream.js b/server/objects/Stream.js
index d9bdd583c..70361463f 100644
--- a/server/objects/Stream.js
+++ b/server/objects/Stream.js
@@ -73,7 +73,7 @@ class Stream extends EventEmitter {
return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
}
get codecsToForceAAC() {
- return ['alac']
+ return ['alac', 'ac3', 'eac3', 'opus']
}
get userToken() {
return this.user.token
@@ -273,7 +273,16 @@ class Stream extends EventEmitter {
audioCodec = 'aac'
}
- this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`])
+ const codecOptions = [`-loglevel ${logLevel}`, '-map 0:a']
+
+ if (['ac3', 'eac3'].includes(this.tracksCodec) && this.tracks.length > 0 && this.tracks[0].bitRate && this.tracks[0].channels) {
+ // In case for ac3/eac3 it needs to be passed the bitrate and channels to avoid ffmpeg errors
+ codecOptions.push(`-c:a ${audioCodec}`, `-b:a ${this.tracks[0].bitRate}`, `-ac ${this.tracks[0].channels}`)
+ } else {
+ codecOptions.push(`-c:a ${audioCodec}`)
+ }
+
+ this.ffmpeg.addOption(codecOptions)
const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0']
this.ffmpeg.addOption(hlsOptions)
if (this.hlsSegmentType === 'fmp4') {
diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js
index a03e17c75..f0bb4b201 100644
--- a/server/objects/settings/ServerSettings.js
+++ b/server/objects/settings/ServerSettings.js
@@ -3,6 +3,7 @@ const packageJson = require('../../../package.json')
const { BookshelfView } = require('../../utils/constants')
const Logger = require('../../Logger')
const User = require('../../models/User')
+const { sanitize } = require('../../utils/htmlSanitizer')
class ServerSettings {
constructor(settings) {
@@ -126,7 +127,7 @@ class ServerSettings {
this.version = settings.version || null
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
- this.authLoginCustomMessage = settings.authLoginCustomMessage || null // Added v2.8.0
+ this.authLoginCustomMessage = sanitize(settings.authLoginCustomMessage) || null // Added v2.8.0
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null
@@ -259,6 +260,18 @@ class ServerSettings {
}
}
+ /**
+ * Host timezone used by cron schedulers (not persisted in settings)
+ * @returns {string} IANA timezone name, e.g. "America/New_York"
+ */
+ static getHostTimeZone() {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
+ } catch {
+ return 'UTC'
+ }
+ }
+
toJSONForBrowser() {
const json = this.toJSON()
delete json.tokenSecret
@@ -267,6 +280,7 @@ class ServerSettings {
delete json.authOpenIDMobileRedirectURIs
delete json.authOpenIDGroupClaim
delete json.authOpenIDAdvancedPermsClaim
+ json.timeZone = ServerSettings.getHostTimeZone()
return json
}
@@ -309,7 +323,7 @@ class ServerSettings {
get authFormData() {
const clientFormData = {
- authLoginCustomMessage: this.authLoginCustomMessage
+ authLoginCustomMessage: sanitize(this.authLoginCustomMessage)
}
if (this.authActiveAuthMethods.includes('openid')) {
clientFormData.authOpenIDButtonText = this.authOpenIDButtonText
@@ -327,6 +341,9 @@ class ServerSettings {
update(payload) {
let hasUpdates = false
for (const key in payload) {
+ if (key === 'authLoginCustomMessage') {
+ payload[key] = sanitize(payload[key])
+ }
if (key === 'sortingPrefixes') {
// Sorting prefixes are updated with the /api/sorting-prefixes endpoint
continue
diff --git a/server/providers/Audible.js b/server/providers/Audible.js
index 2c12ffc1a..133d3c0d8 100644
--- a/server/providers/Audible.js
+++ b/server/providers/Audible.js
@@ -57,8 +57,13 @@ class Audible {
})
}
- const genresFiltered = genres ? genres.filter((g) => g.type == 'genre').map((g) => g.name) : []
- const tagsFiltered = genres ? genres.filter((g) => g.type == 'tag').map((g) => g.name) : []
+ let genresCleaned = []
+ let tagsCleaned = []
+
+ if (genres && Array.isArray(genres)) {
+ genresCleaned = [...new Set(genres.filter((g) => g.type == 'genre').map((g) => g.name))]
+ tagsCleaned = [...new Set(genres.filter((g) => g.type == 'tag').map((g) => g.name))]
+ }
return {
title,
@@ -71,8 +76,8 @@ class Audible {
cover: image,
asin,
isbn,
- genres: genresFiltered.length ? genresFiltered : null,
- tags: tagsFiltered.length ? tagsFiltered.join(', ') : null,
+ genres: genresCleaned.length ? genresCleaned : null,
+ tags: tagsCleaned.length ? tagsCleaned : null,
series: series.length ? series : null,
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js
index c079a1280..5c8cad75a 100644
--- a/server/providers/CustomProviderAdapter.js
+++ b/server/providers/CustomProviderAdapter.js
@@ -89,6 +89,27 @@ class CustomProviderAdapter {
})
.filter((s) => s !== undefined)
}
+ /**
+ * Validates and dedupes tags/genres array
+ * Can be comma separated string or array of strings
+ * @param {string|string[]} tagsGenres
+ * @returns {string[]}
+ */
+ const validateTagsGenresArray = (tagsGenres) => {
+ if (!tagsGenres || (typeof tagsGenres !== 'string' && !Array.isArray(tagsGenres))) return undefined
+
+ // If string, split by comma and trim each item
+ if (typeof tagsGenres === 'string') tagsGenres = tagsGenres.split(',')
+ // If array, ensure all items are strings
+ else if (!tagsGenres.every((t) => typeof t === 'string')) return undefined
+
+ // Trim and filter out empty strings
+ tagsGenres = tagsGenres.map((t) => t.trim()).filter(Boolean)
+ if (!tagsGenres.length) return undefined
+
+ // Dedup
+ return [...new Set(tagsGenres)]
+ }
// re-map keys to throw out
return matches.map((match) => {
@@ -105,8 +126,8 @@ class CustomProviderAdapter {
cover: toStringOrUndefined(cover),
isbn: toStringOrUndefined(isbn),
asin: toStringOrUndefined(asin),
- genres: Array.isArray(genres) && genres.every((g) => typeof g === 'string') ? genres : undefined,
- tags: toStringOrUndefined(tags),
+ genres: validateTagsGenresArray(genres),
+ tags: validateTagsGenresArray(tags),
series: validateSeriesArray(series),
language: toStringOrUndefined(language),
duration: !isNaN(duration) && duration !== null ? Number(duration) : undefined
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index 6446ecc80..3ade851bb 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -171,6 +171,10 @@ class ApiRouter {
// Current User Routes (Me)
//
this.router.get('/me', MeController.getCurrentUser.bind(this))
+ this.router.get('/me/sessions', MeController.getSessions.bind(this))
+ this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this))
+ this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this))
+ this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this))
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
@@ -283,6 +287,7 @@ class ApiRouter {
this.router.get('/search/podcast', SearchController.findPodcasts.bind(this))
this.router.get('/search/authors', SearchController.findAuthor.bind(this))
this.router.get('/search/chapters', SearchController.findChapters.bind(this))
+ this.router.get('/search/providers', SearchController.getAllProviders.bind(this))
//
// Cache Routes (Admin and up)
@@ -362,8 +367,9 @@ class ApiRouter {
* Remove library item and associated entities
* @param {string} libraryItemId
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
+ * @param {string} libraryId
*/
- async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
+ async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) {
const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: mediaItemIds
@@ -394,7 +400,8 @@ class ApiRouter {
await Database.libraryItemModel.removeById(libraryItemId)
SocketAuthority.emitter('item_removed', {
- id: libraryItemId
+ id: libraryItemId,
+ libraryId
})
}
diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js
index e554dfb44..0ea34b1bb 100644
--- a/server/scanner/AbsMetadataFileScanner.js
+++ b/server/scanner/AbsMetadataFileScanner.js
@@ -5,15 +5,15 @@ const { LogLevel } = require('../utils/constants')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
class AbsMetadataFileScanner {
- constructor() { }
+ constructor() {}
/**
* Check for metadata.json file and set book metadata
- *
- * @param {import('./LibraryScan')} libraryScan
- * @param {import('./LibraryItemScanData')} libraryItemData
- * @param {Object} bookMetadata
- * @param {string} [existingLibraryItemId]
+ *
+ * @param {import('./LibraryScan')} libraryScan
+ * @param {import('./LibraryItemScanData')} libraryItemData
+ * @param {Object} bookMetadata
+ * @param {string} [existingLibraryItemId]
*/
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
@@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
- const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
+ const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
+
for (const key in abMetadata) {
// TODO: When to override with null or empty arrays?
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
@@ -48,11 +49,11 @@ class AbsMetadataFileScanner {
/**
* Check for metadata.json file and set podcast metadata
- *
- * @param {import('./LibraryScan')} libraryScan
- * @param {import('./LibraryItemScanData')} libraryItemData
- * @param {Object} podcastMetadata
- * @param {string} [existingLibraryItemId]
+ *
+ * @param {import('./LibraryScan')} libraryScan
+ * @param {import('./LibraryItemScanData')} libraryItemData
+ * @param {Object} podcastMetadata
+ * @param {string} [existingLibraryItemId]
*/
async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
@@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
- const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
+ const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
for (const key in abMetadata) {
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
if (key === 'tags' && !abMetadata.tags?.length) continue
@@ -81,4 +82,4 @@ class AbsMetadataFileScanner {
}
}
}
-module.exports = new AbsMetadataFileScanner()
\ No newline at end of file
+module.exports = new AbsMetadataFileScanner()
diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js
index a1e7ff507..2b106cdc8 100644
--- a/server/scanner/BookScanner.js
+++ b/server/scanner/BookScanner.js
@@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
+const htmlSanitizer = require('../utils/htmlSanitizer')
const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database')
@@ -227,6 +228,7 @@ class BookScanner {
bookId: media.id,
authorId: existingAuthorId
})
+ libraryScan.authorsNumBooksChangedIds.add(existingAuthorId)
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
authorsUpdated = true
} else {
@@ -237,6 +239,7 @@ class BookScanner {
})
await media.addAuthor(newAuthor)
Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id)
+ SocketAuthority.emitter('author_added', newAuthor.toOldJSON())
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`)
authorsUpdated = true
}
@@ -246,6 +249,7 @@ class BookScanner {
for (const author of media.authors) {
if (!bookMetadata.authors.includes(author.name)) {
await author.bookAuthor.destroy()
+ libraryScan.authorsNumBooksChangedIds.add(author.id)
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`)
authorsUpdated = true
bookAuthorsRemoved.push(author.id)
@@ -477,6 +481,7 @@ class BookScanner {
}
const createdAtTimestamp = new Date().getTime()
+ const newAuthorNames = new Set()
if (bookMetadata.authors.length) {
for (const authorName of bookMetadata.authors) {
const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)
@@ -484,6 +489,8 @@ class BookScanner {
bookObject.bookAuthors.push({
authorId: matchingAuthorId
})
+ // Existing author â only numBooks changes; batch emit at scan end
+ libraryScan.authorsNumBooksChangedIds.add(matchingAuthorId)
} else {
// New author
bookObject.bookAuthors.push({
@@ -495,6 +502,7 @@ class BookScanner {
lastFirst: Database.authorModel.getLastFirst(authorName)
}
})
+ newAuthorNames.add(authorName)
}
}
}
@@ -592,6 +600,9 @@ class BookScanner {
for (const ba of libraryItem.book.bookAuthors) {
if (ba.author) {
Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id)
+ if (newAuthorNames.has(ba.author.name)) {
+ SocketAuthority.emitter('author_added', ba.author.toOldJSON())
+ }
}
}
}
@@ -688,6 +699,10 @@ class BookScanner {
bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
+ if (typeof bookMetadata.description === 'string' && bookMetadata.description) {
+ bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description)
+ }
+
return bookMetadata
}
@@ -820,6 +835,9 @@ class BookScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
+ /**
+ * Keys must match abmetadataGenerator.js
+ */
const jsonObject = {
tags: libraryItem.media.tags || [],
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],
@@ -883,6 +901,34 @@ class BookScanner {
})
}
+ /**
+ * Emit authors_num_books_updated for authors whose book count changed during a scan (linked or unlinked).
+ * Authors with no book links are omitted â orphans are handled by author_removed.
+ * @param {string} libraryId
+ * @param {import('./LibraryScan')|import('./ScanLogger')} scanLogger
+ * @returns {Promise}
+ */
+ async emitAuthorsNumBooksUpdated(libraryId, scanLogger) {
+ if (!scanLogger.authorsNumBooksChangedIds?.size) return
+
+ const authorIds = [...scanLogger.authorsNumBooksChangedIds]
+ scanLogger.authorsNumBooksChangedIds.clear()
+
+ const countsByAuthorId = await Database.bookAuthorModel.getCountsForAuthors(authorIds)
+
+ const authors = Object.entries(countsByAuthorId).map(([id, numBooks]) => ({
+ id,
+ numBooks
+ }))
+
+ if (!authors.length) return
+
+ SocketAuthority.emitter('authors_num_books_updated', {
+ libraryId,
+ authors
+ })
+ }
+
/**
* Check authors that were removed from a book and remove them if they no longer have any books
* keep authors without books that have a asin, description or imagePath
diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js
index 501df4274..de95c9a09 100644
--- a/server/scanner/LibraryItemScanner.js
+++ b/server/scanner/LibraryItemScanner.js
@@ -66,7 +66,7 @@ class LibraryItemScanner {
if (libraryItemDataUpdated || wasUpdated) {
SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem)
- await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)
+ await this.finalizeAuthorsAndSeriesFromScan(library.id, scanLogger)
return ScanResult.UPDATED
}
@@ -76,12 +76,13 @@ class LibraryItemScanner {
}
/**
- * Remove empty authors and series
+ * Emit author book count updates, then remove empty authors/series left after the scan
* @param {string} libraryId
* @param {ScanLogger} scanLogger
* @returns {Promise}
*/
- async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) {
+ async finalizeAuthorsAndSeriesFromScan(libraryId, scanLogger) {
+ await BookScanner.emitAuthorsNumBooksUpdated(libraryId, scanLogger)
if (scanLogger.authorsRemovedFromBooks.length) {
await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)
}
@@ -215,7 +216,10 @@ class LibraryItemScanner {
scanLogger.verbose = true
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
- return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
+ const newLibraryItem = await this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
+ // Watcher path does not go through full-library scan cleanup â emit author book count updates here
+ await BookScanner.emitAuthorsNumBooksUpdated(library.id, scanLogger)
+ return newLibraryItem
}
}
module.exports = new LibraryItemScanner()
diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js
index e77f30ba6..0366addba 100644
--- a/server/scanner/LibraryScan.js
+++ b/server/scanner/LibraryScan.js
@@ -24,6 +24,8 @@ class LibraryScan {
/** @type {string[]} */
this.authorsRemovedFromBooks = []
+ /** @type {Set} */
+ this.authorsNumBooksChangedIds = new Set()
/** @type {string[]} */
this.seriesRemovedFromBooks = []
diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js
index 640c82d76..73cb51553 100644
--- a/server/scanner/LibraryScanner.js
+++ b/server/scanner/LibraryScanner.js
@@ -234,8 +234,7 @@ class LibraryScanner {
SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)
}
- // Authors and series that were removed from books should be removed if they are now empty
- await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan)
+ if (this.shouldCancelScan(libraryScan)) return true
// Update missing library items
if (libraryItemIdsMissing.length) {
@@ -281,6 +280,9 @@ class LibraryScanner {
}
}
+ // Author book count updates and empty authors/series cleanup after all items are processed
+ await LibraryItemScanner.finalizeAuthorsAndSeriesFromScan(libraryScan.libraryId, libraryScan)
+
libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
return false
}
diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js
index c9569c3ad..f3241d536 100644
--- a/server/scanner/PodcastScanner.js
+++ b/server/scanner/PodcastScanner.js
@@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile')
const fsExtra = require('../libs/fsExtra')
const PodcastEpisode = require('../models/PodcastEpisode')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
+const htmlSanitizer = require('../utils/htmlSanitizer')
/**
* Metadata for podcasts pulled from files
@@ -398,6 +399,10 @@ class PodcastScanner {
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
+ if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
+ podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
+ }
+
return podcastMetadata
}
@@ -420,6 +425,9 @@ class PodcastScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
+ /**
+ * Keys must match abmetadataGenerator.js
+ */
const jsonObject = {
tags: libraryItem.media.tags || [],
title: libraryItem.media.title,
diff --git a/server/scanner/ScanLogger.js b/server/scanner/ScanLogger.js
index 7aed21f13..2748f1f3b 100644
--- a/server/scanner/ScanLogger.js
+++ b/server/scanner/ScanLogger.js
@@ -14,6 +14,8 @@ class ScanLogger {
/** @type {string[]} */
this.authorsRemovedFromBooks = []
+ /** @type {Set} */
+ this.authorsNumBooksChangedIds = new Set()
/** @type {string[]} */
this.seriesRemovedFromBooks = []
diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js
index 206068cc4..af4405987 100644
--- a/server/scanner/Scanner.js
+++ b/server/scanner/Scanner.js
@@ -259,18 +259,17 @@ class Scanner {
SocketAuthority.emitter('author_added', author.toOldJSON())
// Update filter data
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
-
- await Database.bookAuthorModel
- .create({
- authorId: author.id,
- bookId: libraryItem.media.id
- })
- .then(() => {
- Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added author "${author.name}" to "${libraryItem.media.title}"`)
- libraryItem.media.authors.push(author)
- hasAuthorUpdates = true
- })
}
+ await Database.bookAuthorModel
+ .create({
+ authorId: author.id,
+ bookId: libraryItem.media.id
+ })
+ .then(() => {
+ Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added author "${author.name}" to "${libraryItem.media.title}"`)
+ libraryItem.media.authors.push(author)
+ hasAuthorUpdates = true
+ })
}
const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => ma.toLowerCase() === a.name.toLowerCase()))
if (authorsRemoved.length) {
diff --git a/server/utils/constants.js b/server/utils/constants.js
index cc5217f41..925035e17 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -48,6 +48,8 @@ module.exports.AudioMimeType = {
AIF: 'audio/x-aiff',
WEBM: 'audio/webm',
WEBMA: 'audio/webm',
+ // TODO: Switch to `audio/matroska`? marked as deprecated in IANA registry
+ // ref: https://datatracker.ietf.org/doc/html/rfc9559
MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb',
CAF: 'audio/x-caf',
diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js
index 80832cc77..7ad2a3aee 100644
--- a/server/utils/ffmpegHelpers.js
+++ b/server/utils/ffmpegHelpers.js
@@ -1,4 +1,5 @@
const axios = require('axios')
+const ssrfFilter = require('ssrf-req-filter')
const Ffmpeg = require('../libs/fluentFfmpeg')
const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
const fs = require('../libs/fsExtra')
@@ -97,6 +98,8 @@ async function resizeImage(filePath, outputPath, width, height) {
module.exports.resizeImage = resizeImage
/**
+ * Download podcast episode
+ * Uses SSRF filter to prevent internal URLs
*
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
* @returns {Promise<{success: boolean, isRequestError?: boolean}>}
@@ -121,7 +124,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
Accept: '*/*',
'User-Agent': userAgent
},
- timeout: global.PodcastDownloadTimeout
+ timeout: global.PodcastDownloadTimeout,
+ httpAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url),
+ httpsAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url)
})
Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`)
diff --git a/server/utils/generators/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js
index 01f853288..d0d278752 100644
--- a/server/utils/generators/abmetadataGenerator.js
+++ b/server/utils/generators/abmetadataGenerator.js
@@ -1,7 +1,51 @@
const Logger = require('../../Logger')
const parseSeriesString = require('../parsers/parseSeriesString')
-function parseJsonMetadataText(text) {
+const mediaTypeKeys = {
+ book: {
+ tags: 'stringArray',
+ title: 'string',
+ subtitle: 'string',
+ authors: 'stringArray',
+ narrators: 'stringArray',
+ series: 'stringArray',
+ genres: 'stringArray',
+ publishedYear: 'string',
+ publishedDate: 'string',
+ publisher: 'string',
+ description: 'string',
+ isbn: 'string',
+ asin: 'string',
+ language: 'string',
+ explicit: 'boolean',
+ abridged: 'boolean'
+ },
+ podcast: {
+ tags: 'stringArray',
+ title: 'string',
+ author: 'string',
+ description: 'string',
+ releaseDate: 'string',
+ genres: 'stringArray',
+ feedURL: 'string',
+ imageURL: 'string',
+ itunesPageURL: 'string',
+ itunesId: 'string',
+ itunesArtistId: 'string',
+ asin: 'string',
+ language: 'string',
+ explicit: 'boolean',
+ podcastType: 'string'
+ }
+}
+
+/**
+ *
+ * @param {string} text
+ * @param {"book" | "podcast"} mediaType
+ * @returns {Object}
+ */
+function parseJsonMetadataText(text, mediaType) {
try {
const abmetadataData = JSON.parse(text)
@@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
}
delete abmetadataData.metadata
- if (abmetadataData.series?.length) {
- abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))]
- abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series))
+ const expectedKeys = mediaTypeKeys[mediaType]
+ if (!expectedKeys) {
+ Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
+ return null
}
- // clean tags & remove dupes
- if (abmetadataData.tags?.length) {
- abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))]
+
+ const validated = {}
+ for (const key in expectedKeys) {
+ const expectedType = expectedKeys[key]
+ if (!(key in abmetadataData)) continue
+
+ const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType)
+ if (validatedValue !== undefined) {
+ validated[key] = validatedValue
+ }
}
- if (abmetadataData.chapters?.length) {
- abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)
+
+ if (validated.series?.length) {
+ validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
}
- // clean remove dupes
- if (abmetadataData.authors?.length) {
- abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))]
+
+ if (mediaType === 'book' && 'chapters' in abmetadataData) {
+ if (abmetadataData.chapters === null) {
+ validated.chapters = []
+ } else if (Array.isArray(abmetadataData.chapters)) {
+ const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title)
+ if (cleanedChapters) {
+ validated.chapters = cleanedChapters
+ }
+ } else {
+ Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`)
+ }
}
- if (abmetadataData.narrators?.length) {
- abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))]
- }
- if (abmetadataData.genres?.length) {
- abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
- }
- return abmetadataData
+
+ return validated
} catch (error) {
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
return null
@@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
}
module.exports.parseJson = parseJsonMetadataText
+/**
+ * @param {string} key
+ * @param {*} value
+ * @param {string} expectedType
+ * @returns {*|undefined} undefined excludes the key
+ */
+function validateMetadataValue(key, value, expectedType) {
+ if (expectedType === 'string') {
+ if (value === null) return null
+ if (typeof value === 'number') return String(value)
+ if (typeof value === 'string') return value
+ Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`)
+ return undefined
+ }
+
+ if (expectedType === 'boolean') {
+ if (value === null) return null
+ if (typeof value === 'boolean') return value
+ if (typeof value === 'string') {
+ const lower = value.toLowerCase()
+ if (lower === 'true') return true
+ if (lower === 'false') return false
+ }
+ Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`)
+ return undefined
+ }
+
+ // Filter empty strings and deduplicate
+ if (expectedType === 'stringArray') {
+ if (value === null) return []
+ if (!Array.isArray(value)) {
+ Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`)
+ return undefined
+ }
+
+ const cleanedArray = value.filter((t) => typeof t === 'string')
+ return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
+ }
+
+ Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
+ return undefined
+}
+
+/**
+ * @param {Object[]} chaptersArray
+ * @param {string} mediaTitle
+ * @returns {Object[]}
+ */
function cleanChaptersArray(chaptersArray, mediaTitle) {
const chapters = []
let index = 0
diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js
index 4ed30e727..be839b7c2 100644
--- a/server/utils/htmlSanitizer.js
+++ b/server/utils/htmlSanitizer.js
@@ -5,11 +5,10 @@ const { entities } = require('./htmlEntities')
*
* @param {string} html
* @returns {string}
- * @throws {Error} if input is not a string
*/
function sanitize(html) {
if (typeof html !== 'string') {
- throw new Error('sanitizeHtml: input must be a string')
+ return ''
}
const sanitizerOptions = {
@@ -27,6 +26,8 @@ function sanitize(html) {
module.exports.sanitize = sanitize
function stripAllTags(html, shouldDecodeEntities = true) {
+ if (typeof html !== 'string') return ''
+
const sanitizerOptions = {
allowedTags: [],
disallowedTagsMode: 'discard'
diff --git a/server/utils/index.js b/server/utils/index.js
index 369620276..49a7c8e67 100644
--- a/server/utils/index.js
+++ b/server/utils/index.js
@@ -54,6 +54,16 @@ module.exports.isNullOrNaN = (num) => {
return num === null || isNaN(num)
}
+/**
+ * @param {number|null|undefined} value
+ * @param {number} max
+ * @returns {number|null}
+ */
+module.exports.clampPositiveInt = (value, max) => {
+ if (value == null || !Number.isFinite(value) || value <= 0) return null
+ return Math.min(Math.floor(value), max)
+}
+
const xmlToJSON = (xml) => {
return new Promise((resolve, reject) => {
parseString(xml, (err, results) => {
@@ -277,3 +287,57 @@ module.exports.timestampToSeconds = (timestamp) => {
}
return null
}
+
+class ValidationError extends Error {
+ constructor(paramName, message, status = 400) {
+ super(`Query parameter "${paramName}" ${message}`)
+ this.name = 'ValidationError'
+ this.paramName = paramName
+ this.status = status
+ }
+}
+module.exports.ValidationError = ValidationError
+
+class NotFoundError extends Error {
+ constructor(message, status = 404) {
+ super(message)
+ this.name = 'NotFoundError'
+ this.status = status
+ }
+}
+module.exports.NotFoundError = NotFoundError
+
+/**
+ * Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion
+ * Express query parameters can be arrays if the same parameter appears multiple times
+ * @example ?author=Smith => "Smith"
+ * @example ?author=Smith&author=Jones => throws error
+ *
+ * @param {Object} query - Query object
+ * @param {string} paramName - Parameter name
+ * @param {string} defaultValue - Default value if undefined/null
+ * @param {boolean} required - Whether the parameter is required
+ * @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks)
+ * @returns {string} String value
+ * @throws {ValidationError} If value is an array
+ * @throws {ValidationError} If value is too long
+ * @throws {ValidationError} If value is required but not provided
+ */
+module.exports.getQueryParamAsString = (query, paramName, defaultValue = '', required = false, maxLength = 1000) => {
+ const value = query[paramName]
+ if (value === undefined || value === null) {
+ if (required) {
+ throw new ValidationError(paramName, 'is required')
+ }
+ return defaultValue
+ }
+ // Explicitly reject arrays to prevent type confusion
+ if (Array.isArray(value)) {
+ throw new ValidationError(paramName, 'is an array')
+ }
+ // Reject excessively long strings to prevent ReDoS attacks
+ if (typeof value === 'string' && value.length > maxLength) {
+ throw new ValidationError(paramName, 'is too long')
+ }
+ return String(value)
+}
diff --git a/server/utils/notifications.js b/server/utils/notifications.js
index 700d3c389..497d7a1a1 100644
--- a/server/utils/notifications.js
+++ b/server/utils/notifications.js
@@ -100,7 +100,7 @@ module.exports.notificationData = {
variables: ['version'],
defaults: {
title: 'Test Notification on Abs {{version}}',
- body: 'Test notificataion body for abs {{version}}.'
+ body: 'Test notification body for abs {{version}}.'
},
testData: {
version: 'v' + version
diff --git a/server/utils/parsers/parseNfoMetadata.js b/server/utils/parsers/parseNfoMetadata.js
index 6682a0078..4276443d0 100644
--- a/server/utils/parsers/parseNfoMetadata.js
+++ b/server/utils/parsers/parseNfoMetadata.js
@@ -22,7 +22,7 @@ function parseNfoMetadata(nfoText) {
switch (key) {
case 'title':
{
- const titleMatch = value.match(/^(.*?):(.*)$/)
+ const titleMatch = value.match(/^(.*?): (.*)$/)
if (titleMatch) {
metadata.title = titleMatch[1].trim()
metadata.subtitle = titleMatch[2].trim()
diff --git a/server/utils/parsers/parseUserAgent.js b/server/utils/parsers/parseUserAgent.js
new file mode 100644
index 000000000..045074429
--- /dev/null
+++ b/server/utils/parsers/parseUserAgent.js
@@ -0,0 +1,28 @@
+const uaParserJs = require('../../libs/uaParser')
+
+/**
+ * @param {string|null|undefined} userAgent
+ * @returns {{ browserName?:string, browserVersion?:string, osName?:string, osVersion?:string, deviceType?:string, model?:string, vendor?:string }|null}
+ */
+function parseUserAgent(userAgent) {
+ if (!userAgent) return null
+
+ const ua = uaParserJs(userAgent)
+ const deviceInfo = {
+ browserName: ua?.browser?.name || undefined,
+ browserVersion: ua?.browser?.version || undefined,
+ osName: ua?.os?.name || undefined,
+ osVersion: ua?.os?.version || undefined,
+ deviceType: ua?.device?.type || undefined,
+ model: ua?.device?.model || undefined,
+ vendor: ua?.device?.vendor || undefined
+ }
+
+ for (const key in deviceInfo) {
+ if (deviceInfo[key] === undefined) delete deviceInfo[key]
+ }
+
+ return Object.keys(deviceInfo).length ? deviceInfo : null
+}
+
+module.exports = parseUserAgent
diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js
index b62e024f0..1cb0c4cb4 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -171,7 +171,7 @@ function extractEpisodeData(item) {
// Full description with html
if (item['content:encoded']) {
- const rawDescription = (extractFirstArrayItem(item, 'content:encoded') || '').trim()
+ const rawDescription = (extractFirstArrayItemString(item, 'content:encoded') || '').trim()
episode.description = htmlSanitizer.sanitize(rawDescription)
}
@@ -217,6 +217,10 @@ function extractEpisodeData(item) {
episode[cleanKey] = extractFirstArrayItemString(item, key)
})
+ if (episode.subtitle) {
+ episode.subtitle = htmlSanitizer.sanitize(episode.subtitle.trim())
+ }
+
// Extract psc:chapters if duration is set
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index 494a9564f..fbe0c4f0d 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -236,7 +236,7 @@ module.exports = {
} else if (group === 'publishedDecades') {
const startYear = parseInt(value)
const endYear = parseInt(value, 10) + 9
- mediaWhere = Sequelize.where(Sequelize.literal('CAST(`book`.`publishedYear` AS INTEGER)'), {
+ mediaWhere = Sequelize.where(Sequelize.literal('CAST(publishedYear AS INTEGER)'), {
[Sequelize.Op.between]: [startYear, endYear]
})
}
@@ -888,28 +888,79 @@ module.exports = {
})
}
- // Step 2: Get books not started and not in a series OR is the first book of a series not started (ordered randomly)
- const { rows: books, count } = await Database.bookModel.findAndCountAll({
- where: [
- {
- '$mediaProgresses.isFinished$': {
- [Sequelize.Op.or]: [null, 0]
- },
- '$mediaProgresses.currentTime$': {
- [Sequelize.Op.or]: [null, 0]
- },
- [Sequelize.Op.or]: [
- Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0),
- {
- id: {
- [Sequelize.Op.in]: booksFromSeriesToInclude
- }
- }
- ]
+ const discoverWhere = [
+ {
+ '$mediaProgresses.isFinished$': {
+ [Sequelize.Op.or]: [null, 0]
},
- ...userPermissionBookWhere.bookWhere
- ],
+ '$mediaProgresses.currentTime$': {
+ [Sequelize.Op.or]: [null, 0]
+ },
+ [Sequelize.Op.or]: [
+ Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0),
+ {
+ id: {
+ [Sequelize.Op.in]: booksFromSeriesToInclude
+ }
+ }
+ ]
+ },
+ ...userPermissionBookWhere.bookWhere
+ ]
+
+ const baseDiscoverInclude = [
+ {
+ model: Database.libraryItemModel,
+ where: {
+ libraryId
+ }
+ },
+ {
+ model: Database.mediaProgressModel,
+ where: {
+ userId: user.id
+ },
+ required: false
+ }
+ ]
+
+ // Step 2a: Count with lightweight includes only
+ const count = await Database.bookModel.count({
+ where: discoverWhere,
replacements: userPermissionBookWhere.replacements,
+ include: baseDiscoverInclude,
+ distinct: true,
+ col: 'id',
+ subQuery: false
+ })
+
+ // Step 2b: Select random IDs with lightweight includes only
+ const randomSelection = await Database.bookModel.findAll({
+ attributes: ['id'],
+ where: discoverWhere,
+ replacements: userPermissionBookWhere.replacements,
+ include: baseDiscoverInclude,
+ subQuery: false,
+ distinct: true,
+ limit,
+ order: Database.sequelize.random()
+ })
+
+ const selectedIds = randomSelection.map((b) => b.id).filter(Boolean)
+ if (!selectedIds.length) {
+ return {
+ libraryItems: [],
+ count
+ }
+ }
+
+ // Step 2c: Hydrate selected IDs with full metadata for API response
+ const books = await Database.bookModel.findAll({
+ where: {
+ id: {
+ [Sequelize.Op.in]: selectedIds
+ }
+ },
include: [
{
model: Database.libraryItemModel,
@@ -918,13 +969,6 @@ module.exports = {
},
include: libraryItemIncludes
},
- {
- model: Database.mediaProgressModel,
- where: {
- userId: user.id
- },
- required: false
- },
{
model: Database.bookAuthorModel,
attributes: ['authorId'],
@@ -942,14 +986,14 @@ module.exports = {
separate: true
}
],
- subQuery: false,
- distinct: true,
- limit,
- order: Database.sequelize.random()
+ subQuery: false
})
+ const booksById = new Map(books.map((b) => [b.id, b]))
+ const orderedBooks = selectedIds.map((id) => booksById.get(id)).filter(Boolean)
+
// Step 3: Map books to library items
- const libraryItems = books.map((bookExpanded) => {
+ const libraryItems = orderedBooks.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem
const book = bookExpanded
delete book.libraryItem
diff --git a/server/utils/requestUtils.js b/server/utils/requestUtils.js
new file mode 100644
index 000000000..afac4fcc0
--- /dev/null
+++ b/server/utils/requestUtils.js
@@ -0,0 +1,43 @@
+/**
+ * Whether the request was made over HTTPS.
+ * Uses Express `req.secure` and `x-forwarded-proto`
+ *
+ * @param {import('express').Request} req
+ * @returns {boolean}
+ */
+function isRequestSecure(req) {
+ if (req.secure) return true
+ const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
+ // Nginx Proxy Manager sends "http, https"; see https://github.com/advplyr/audiobookshelf/pull/4635
+ return (
+ xfp === 'https' ||
+ xfp
+ .split(',')
+ .map((s) => s.trim())
+ .includes('https')
+ )
+}
+
+/**
+ * @param {import('express').Request} req
+ * @returns {'https' | 'http'}
+ */
+function getRequestProtocol(req) {
+ return isRequestSecure(req) ? 'https' : 'http'
+}
+
+/**
+ * @param {import('express').Request} req
+ * @returns {{ protocol: 'https' | 'http', host: string, origin: string }}
+ */
+function getRequestOrigin(req) {
+ const protocol = getRequestProtocol(req)
+ const host = req.get('host')
+ return { protocol, host, origin: `${protocol}://${host}` }
+}
+
+module.exports = {
+ isRequestSecure,
+ getRequestProtocol,
+ getRequestOrigin
+}
diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js
new file mode 100644
index 000000000..6a84edac0
--- /dev/null
+++ b/test/server/auth/OidcAuthStrategy.test.js
@@ -0,0 +1,77 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+
+// Load Database first so Auth resolves OidcAuthStrategy before the circular require completes.
+require('../../../server/Database')
+const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
+const Logger = require('../../../server/Logger')
+
+describe('OidcAuthStrategy - isValidWebCallbackUrl', () => {
+ /** @type {OidcAuthStrategy} */
+ let strategy
+
+ beforeEach(() => {
+ global.RouterBasePath = ''
+ strategy = new OidcAuthStrategy()
+ sinon.stub(Logger, 'warn')
+ sinon.stub(Logger, 'error')
+ })
+
+ afterEach(() => {
+ sinon.restore()
+ })
+
+ function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) {
+ return {
+ secure,
+ get(header) {
+ if (header === 'host') return host
+ if (header === 'x-forwarded-proto') return xForwardedProto
+ return null
+ }
+ }
+ }
+
+ it('accepts a same-origin relative path when router base path is empty', () => {
+ expect(strategy.isValidWebCallbackUrl('/library', mockReq())).to.equal(true)
+ })
+
+ it('accepts a same-origin absolute https URL', () => {
+ const req = mockReq({ secure: true })
+ expect(strategy.isValidWebCallbackUrl('https://books.example.com/library', req)).to.equal(true)
+ })
+
+ it('rejects protocol-relative URLs', () => {
+ expect(strategy.isValidWebCallbackUrl('//evil.example/capture', mockReq())).to.equal(false)
+ })
+
+ it('rejects backslash-prefixed URLs', () => {
+ expect(strategy.isValidWebCallbackUrl('/\\evil.example/capture', mockReq())).to.equal(false)
+ })
+
+ it('rejects absolute external URLs', () => {
+ expect(strategy.isValidWebCallbackUrl('http://evil.example/capture', mockReq())).to.equal(false)
+ })
+
+ it('rejects encoded protocol-relative path segments', () => {
+ expect(strategy.isValidWebCallbackUrl('/%2F%2Fevil.example/capture', mockReq())).to.equal(false)
+ })
+
+ it('rejects same-origin URLs outside router base path', () => {
+ global.RouterBasePath = '/audiobookshelf'
+ expect(strategy.isValidWebCallbackUrl('/login', mockReq())).to.equal(false)
+ expect(strategy.isValidWebCallbackUrl('/audiobookshelf/login', mockReq())).to.equal(true)
+ })
+
+ it('rejects empty and malformed callback URLs', () => {
+ expect(strategy.isValidWebCallbackUrl('', mockReq())).to.equal(false)
+ expect(strategy.isValidWebCallbackUrl(null, mockReq())).to.equal(false)
+ expect(strategy.isValidWebCallbackUrl('not a url', mockReq())).to.equal(false)
+ })
+
+ it('uses x-forwarded-proto when determining same-origin https URLs', () => {
+ const req = mockReq({ xForwardedProto: 'https' })
+ expect(strategy.isValidWebCallbackUrl('https://books.example.com/login', req)).to.equal(true)
+ expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false)
+ })
+})
diff --git a/test/server/auth/TokenManager.test.js b/test/server/auth/TokenManager.test.js
new file mode 100644
index 000000000..ad583455c
--- /dev/null
+++ b/test/server/auth/TokenManager.test.js
@@ -0,0 +1,142 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+const { Op } = require('sequelize')
+
+const Database = require('../../../server/Database')
+const jwt = require('../../../server/libs/jsonwebtoken')
+
+// Database â Auth â TokenManager circular require can leave TokenManager with a partial Database reference; reload before each test
+function loadTokenManager() {
+ delete require.cache[require.resolve('../../../server/auth/TokenManager')]
+ return require('../../../server/auth/TokenManager')
+}
+
+describe('TokenManager', () => {
+ const secret = 'test-jwt-secret'
+ const userId = 'user-uuid-1'
+ let TokenManager
+ let tokenManager
+
+ beforeEach(() => {
+ TokenManager = loadTokenManager()
+ TokenManager.TokenSecret = secret
+ tokenManager = new TokenManager()
+ })
+
+ afterEach(() => {
+ sinon.restore()
+ })
+
+ describe('validateAccessToken', () => {
+ it('rejects refresh tokens', () => {
+ const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
+ expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
+ })
+
+ it('accepts access tokens', () => {
+ const accessToken = jwt.sign({ userId, type: 'access' }, secret, { expiresIn: 3600 })
+ const decoded = TokenManager.validateAccessToken(accessToken)
+ expect(decoded.userId).to.equal(userId)
+ expect(decoded.type).to.equal('access')
+ })
+ })
+
+ describe('jwtAuthCheck', () => {
+ const user = { id: userId, username: 'testuser', isActive: true }
+
+ it('rejects refresh tokens for API auth', async () => {
+ const refreshToken = tokenManager.generateRefreshToken(user)
+ const decoded = jwt.verify(refreshToken, secret)
+ const done = sinon.spy()
+
+ await tokenManager.jwtAuthCheck(decoded, done)
+
+ expect(done.calledWith(null, null)).to.be.true
+ })
+
+ it('allows access tokens for active users', async () => {
+ sinon.stub(Database, 'userModel').get(() => ({
+ getUserByIdOrOldId: sinon.stub().resolves(user)
+ }))
+ const decoded = jwt.verify(tokenManager.generateTempAccessToken(user), secret)
+ const done = sinon.spy()
+
+ await tokenManager.jwtAuthCheck(decoded, done)
+
+ expect(done.calledWith(null, user)).to.be.true
+ })
+ })
+
+ describe('invalidateJwtSessionsForUser', () => {
+ const targetUser = { id: userId, username: 'testuser' }
+ const currentSession = {
+ id: 'session-current',
+ userId,
+ refreshToken: 'refresh-current'
+ }
+
+ /** Minimal req/res for session invalidation (ApiRouter provides auth on real requests). */
+ function makeReq({ requestUserId = userId, refreshToken = null, cookieRefreshToken = null } = {}) {
+ return {
+ user: { id: requestUserId },
+ cookies: cookieRefreshToken ? { refresh_token: cookieRefreshToken } : {},
+ headers: refreshToken ? { 'x-refresh-token': refreshToken } : {}
+ }
+ }
+
+ let sessionFindOne
+ let sessionDestroy
+ let rotateStub
+
+ beforeEach(() => {
+ sessionFindOne = sinon.stub().resolves(currentSession)
+ sessionDestroy = sinon.stub().resolves(1)
+ sinon.stub(Database, 'sessionModel').get(() => ({
+ findOne: sessionFindOne,
+ destroy: sessionDestroy
+ }))
+ rotateStub = sinon.stub(tokenManager, 'rotateTokensForSession').resolves({
+ accessToken: 'access-new',
+ refreshToken: 'refresh-new'
+ })
+ })
+
+ it('self password change: keeps current session, deletes others', async () => {
+ const req = makeReq({ refreshToken: 'refresh-current' })
+ const res = { cookie: sinon.spy() }
+
+ const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
+
+ // Found this device's session using the x-refresh-token header
+ expect(sessionFindOne.calledOnce).to.be.true
+ const findWhere = sessionFindOne.firstCall.args[0].where
+ expect(findWhere.userId).to.equal(userId)
+ expect(findWhere[Op.or]).to.deep.equal([{ refreshToken: 'refresh-current' }, { lastRefreshToken: 'refresh-current' }])
+
+ // Rotated in place (no grace period) so the caller keeps a valid session
+ expect(rotateStub.calledOnceWith(currentSession, targetUser, req, res, false)).to.be.true
+
+ // Deleted all other sessions, but not this one
+ expect(sessionDestroy.calledOnce).to.be.true
+ const destroyWhere = sessionDestroy.firstCall.args[0].where
+ expect(destroyWhere.userId).to.equal(userId)
+ expect(destroyWhere.id[Op.ne]).to.equal(currentSession.id)
+
+ expect(result).to.deep.equal({ accessToken: 'access-new', refreshToken: 'refresh-new' })
+ })
+
+ it('admin password reset: deletes all target sessions', async () => {
+ const req = makeReq({ requestUserId: 'admin-id', refreshToken: 'refresh-current' })
+ const res = { cookie: sinon.spy() }
+
+ const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
+
+ // Token rotation did not happen because target is a different user
+ expect(sessionFindOne.called).to.be.false
+ expect(rotateStub.called).to.be.false
+
+ expect(sessionDestroy.calledOnceWith(sinon.match({ where: { userId } }))).to.be.true
+ expect(result).to.equal(null)
+ })
+ })
+})
diff --git a/test/server/controllers/LibraryController.test.js b/test/server/controllers/LibraryController.test.js
new file mode 100644
index 000000000..beb8dac03
--- /dev/null
+++ b/test/server/controllers/LibraryController.test.js
@@ -0,0 +1,178 @@
+const { expect } = require('chai')
+const { Sequelize } = require('sequelize')
+const sinon = require('sinon')
+
+const Database = require('../../../server/Database')
+const LibraryController = require('../../../server/controllers/LibraryController')
+const zipHelpers = require('../../../server/utils/zipHelpers')
+const Logger = require('../../../server/Logger')
+
+describe('LibraryController.downloadMultiple', () => {
+ let library
+ let libraryFolder
+ let allowedItemId
+ let explicitItemId
+ let taggedItemId
+ let restrictedUser
+ let libraryRecord
+
+ beforeEach(async () => {
+ global.ServerSettings = {}
+ Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
+ Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
+ await Database.buildModels()
+
+ library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
+ libraryFolder = await Database.libraryFolderModel.create({ path: '/test-lib', libraryId: library.id })
+ libraryRecord = await Database.libraryModel.findByIdWithFolders(library.id)
+
+ const allowedBook = await Database.bookModel.create({
+ title: 'Allowed Book',
+ explicit: false,
+ audioFiles: [],
+ tags: ['allowed-tag'],
+ narrators: [],
+ genres: [],
+ chapters: []
+ })
+ const allowedItem = await Database.libraryItemModel.create({
+ path: '/test-lib/allowed',
+ isFile: false,
+ libraryFiles: [],
+ mediaId: allowedBook.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id
+ })
+ allowedItemId = allowedItem.id
+
+ const explicitBook = await Database.bookModel.create({
+ title: 'Explicit Book',
+ explicit: true,
+ audioFiles: [],
+ tags: [],
+ narrators: [],
+ genres: [],
+ chapters: []
+ })
+ const explicitItem = await Database.libraryItemModel.create({
+ path: '/test-lib/explicit',
+ isFile: false,
+ libraryFiles: [],
+ mediaId: explicitBook.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id
+ })
+ explicitItemId = explicitItem.id
+
+ const taggedBook = await Database.bookModel.create({
+ title: 'Tagged Book',
+ explicit: false,
+ audioFiles: [],
+ tags: ['restricted-tag'],
+ narrators: [],
+ genres: [],
+ chapters: []
+ })
+ const taggedItem = await Database.libraryItemModel.create({
+ path: '/test-lib/tagged',
+ isFile: false,
+ libraryFiles: [],
+ mediaId: taggedBook.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id
+ })
+ taggedItemId = taggedItem.id
+
+ const permissions = Database.userModel.getDefaultPermissionsForUserType('user')
+ permissions.download = true
+ permissions.accessExplicitContent = false
+ permissions.accessAllLibraries = false
+ permissions.accessAllTags = false
+ permissions.librariesAccessible = [library.id]
+ permissions.itemTagsSelected = ['allowed-tag']
+ permissions.selectedTagsNotAccessible = false
+
+ restrictedUser = await Database.userModel.create({
+ username: 'restricted',
+ pash: 'hash',
+ token: 'token',
+ type: 'user',
+ isActive: true,
+ permissions,
+ bookmarks: [],
+ extraData: {}
+ })
+
+ sinon.stub(Logger, 'info')
+ sinon.stub(Logger, 'warn')
+ sinon.stub(Logger, 'error')
+ sinon.stub(zipHelpers, 'zipDirectoriesPipe').resolves()
+ })
+
+ afterEach(async () => {
+ sinon.restore()
+ await Database.sequelize.sync({ force: true })
+ })
+
+ function makeReq(ids) {
+ return {
+ query: { ids: ids.join(',') },
+ user: restrictedUser,
+ library: libraryRecord
+ }
+ }
+
+ function makeRes() {
+ return {
+ sendStatus: sinon.spy(),
+ status: sinon.stub().returnsThis(),
+ send: sinon.spy()
+ }
+ }
+
+ it('returns 403 for bulk download of an explicit item', async () => {
+ const req = makeReq([explicitItemId])
+ const res = makeRes()
+
+ await LibraryController.downloadMultiple(req, res)
+
+ expect(res.sendStatus.calledWith(403)).to.be.true
+ expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
+ })
+
+ it('returns 403 for bulk download of a tag-restricted item', async () => {
+ const req = makeReq([taggedItemId])
+ const res = makeRes()
+
+ await LibraryController.downloadMultiple(req, res)
+
+ expect(res.sendStatus.calledWith(403)).to.be.true
+ expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
+ })
+
+ it('returns 403 when bulk download includes both allowed and forbidden items', async () => {
+ const req = makeReq([allowedItemId, explicitItemId])
+ const res = makeRes()
+
+ await LibraryController.downloadMultiple(req, res)
+
+ expect(res.sendStatus.calledWith(403)).to.be.true
+ expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
+ })
+
+ it('starts zip download for allowed items only', async () => {
+ const req = makeReq([allowedItemId])
+ const res = makeRes()
+
+ await LibraryController.downloadMultiple(req, res)
+
+ expect(res.sendStatus.called).to.be.false
+ expect(zipHelpers.zipDirectoriesPipe.calledOnce).to.be.true
+ const pathObjects = zipHelpers.zipDirectoriesPipe.firstCall.args[0]
+ expect(pathObjects).to.have.length(1)
+ expect(pathObjects[0].path).to.equal('/test-lib/allowed')
+ })
+})
diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js
index 5a0422392..1d57219d3 100644
--- a/test/server/controllers/LibraryItemController.test.js
+++ b/test/server/controllers/LibraryItemController.test.js
@@ -123,7 +123,9 @@ describe('LibraryItemController', () => {
const fakeReq = {
query: {},
user: {
- canDelete: true
+ username: 'test',
+ canDelete: true,
+ checkCanAccessLibraryItem: () => true
},
body: {
libraryItemIds: [libraryItem1Id]
@@ -199,4 +201,102 @@ describe('LibraryItemController', () => {
expect(series2Exists).to.be.true
})
})
+
+ describe('batch item access control', () => {
+ let lib1Id
+ let itemLib1Id
+ let itemLib2Id
+
+ beforeEach(async () => {
+ const lib1 = await Database.libraryModel.create({ name: 'Lib 1', mediaType: 'book' })
+ const folder1 = await Database.libraryFolderModel.create({ path: '/l1', libraryId: lib1.id })
+ const book1 = await Database.bookModel.create({ title: 'B1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
+ const li1 = await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: book1.id,
+ mediaType: 'book',
+ libraryId: lib1.id,
+ libraryFolderId: folder1.id
+ })
+ lib1Id = lib1.id
+ itemLib1Id = li1.id
+
+ const lib2 = await Database.libraryModel.create({ name: 'Lib 2', mediaType: 'book' })
+ const folder2 = await Database.libraryFolderModel.create({ path: '/l2', libraryId: lib2.id })
+ const book2 = await Database.bookModel.create({ title: 'B2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
+ const li2 = await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: book2.id,
+ mediaType: 'book',
+ libraryId: lib2.id,
+ libraryFolderId: folder2.id
+ })
+ itemLib2Id = li2.id
+ })
+
+ const userLimitedToLib1 = () => ({
+ username: 'limited',
+ canDelete: true,
+ canUpdate: true,
+ checkCanAccessLibraryItem(li) {
+ return li.libraryId === lib1Id
+ }
+ })
+
+ it('batchGet returns 403 for a library item the user cannot access', async () => {
+ const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
+ const fakeReq = {
+ body: { libraryItemIds: [itemLib2Id] },
+ user: userLimitedToLib1()
+ }
+ await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
+ expect(fakeRes.sendStatus.calledWith(403)).to.be.true
+ })
+
+ it('batchGet returns items when the user can access them', async () => {
+ const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
+ const fakeReq = {
+ body: { libraryItemIds: [itemLib1Id] },
+ user: userLimitedToLib1()
+ }
+ await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
+ expect(fakeRes.json.calledOnce).to.be.true
+ const payload = fakeRes.json.firstCall.args[0]
+ expect(payload.libraryItems).to.have.length(1)
+ expect(payload.libraryItems[0].id).to.equal(itemLib1Id)
+ })
+
+ it('batchUpdate returns 403 for a library item the user cannot access', async () => {
+ const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
+ const fakeReq = {
+ user: userLimitedToLib1(),
+ body: [{ id: itemLib2Id, mediaPayload: {} }]
+ }
+ await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
+ expect(fakeRes.sendStatus.calledWith(403)).to.be.true
+ })
+
+ it('batchUpdate returns 403 when the user lacks canUpdate', async () => {
+ const u = userLimitedToLib1()
+ u.canUpdate = false
+ const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
+ const fakeReq = {
+ user: u,
+ body: [{ id: itemLib1Id, mediaPayload: {} }]
+ }
+ await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
+ expect(fakeRes.sendStatus.calledWith(403)).to.be.true
+ })
+
+ it('batchDelete returns 403 for a library item the user cannot access', async () => {
+ const fakeRes = { sendStatus: sinon.spy() }
+ const fakeReq = {
+ query: {},
+ user: userLimitedToLib1(),
+ body: { libraryItemIds: [itemLib2Id] }
+ }
+ await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
+ expect(fakeRes.sendStatus.calledWith(403)).to.be.true
+ })
+ })
})
diff --git a/test/server/controllers/UserController.test.js b/test/server/controllers/UserController.test.js
new file mode 100644
index 000000000..add025d6e
--- /dev/null
+++ b/test/server/controllers/UserController.test.js
@@ -0,0 +1,26 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+
+const UserController = require('../../../server/controllers/UserController')
+const Logger = require('../../../server/Logger')
+
+describe('UserController - delete', () => {
+ beforeEach(() => {
+ sinon.stub(Logger, 'error')
+ })
+
+ afterEach(() => {
+ sinon.restore()
+ })
+
+ it('rejects deleting the root user by UUID', async () => {
+ const rootUser = { id: 'root-uuid', isRoot: true, username: 'root' }
+ const adminUser = { id: 'admin-uuid', isRoot: false, username: 'admin' }
+ const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
+
+ await UserController.delete({ user: adminUser, reqUser: rootUser, params: { id: rootUser.id } }, fakeRes)
+
+ expect(fakeRes.sendStatus.calledWith(403)).to.be.true
+ expect(fakeRes.json.called).to.be.false
+ })
+})
diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js
index 6578ca82a..cb697b81a 100644
--- a/test/server/finders/BookFinder.test.js
+++ b/test/server/finders/BookFinder.test.js
@@ -35,7 +35,10 @@ describe('TitleCandidates', () => {
['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, not stripping subtitle for bare colon in title', '10:04', ['10:04']],
+ ['adds candidate, not stripping subtitle for colon between words without space', 'making the mission:impossible movies', ['making the mission:impossible movies']],
['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']],
+ ['adds candidate + variant, removing "by ..." when title has bare colon', '10:04 by ben lerner', ['10:04', '10:04 by ben lerner']],
['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.']],
diff --git a/test/server/managers/ApiCacheManager.test.js b/test/server/managers/ApiCacheManager.test.js
index 19bbeecf6..2e9ad5b77 100644
--- a/test/server/managers/ApiCacheManager.test.js
+++ b/test/server/managers/ApiCacheManager.test.js
@@ -1,6 +1,7 @@
// Import dependencies and modules for testing
const { expect } = require('chai')
const sinon = require('sinon')
+const { LRUCache } = require('lru-cache')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
describe('ApiCacheManager', () => {
@@ -94,4 +95,17 @@ describe('ApiCacheManager', () => {
expect(res.originalSend.calledWith(body)).to.be.true
})
})
+
+ describe('clear on mediaProgress', () => {
+ it('should remove recent-episodes cache entries', () => {
+ const key = JSON.stringify({ user: 'u', url: '/libraries/abc-123/recent-episodes?limit=50&page=0' })
+ const cache = new LRUCache({ max: 10 })
+ cache.set(key, { body: '[]', headers: {}, statusCode: 200 })
+ const manager = new ApiCacheManager(cache)
+
+ manager.clear({ name: 'mediaProgress' }, 'afterUpdate')
+
+ expect(cache.get(key)).to.be.undefined
+ })
+ })
})
diff --git a/test/server/models/oldJsonSerialization.test.js b/test/server/models/oldJsonSerialization.test.js
new file mode 100644
index 000000000..98bab6afa
--- /dev/null
+++ b/test/server/models/oldJsonSerialization.test.js
@@ -0,0 +1,158 @@
+const { expect } = require('chai')
+const { Sequelize } = require('sequelize')
+
+const Database = require('../../../server/Database')
+
+/**
+ * Assert that `expanded` contains every key from `minified` with matching values.
+ * Used to enforce the contract: toOldJSONExpanded() is a strict superset of toOldJSONMinified().
+ *
+ * @param {object} minified
+ * @param {object} expanded
+ * @param {string} [path]
+ */
+function assertExpandedSuperset(minified, expanded, path = '') {
+ for (const key of Object.keys(minified)) {
+ const keyPath = path ? `${path}.${key}` : key
+ expect(expanded, `missing key ${keyPath}`).to.have.property(key)
+
+ const minifiedValue = minified[key]
+ const expandedValue = expanded[key]
+
+ if (minifiedValue !== null && typeof minifiedValue === 'object' && !Array.isArray(minifiedValue)) {
+ assertExpandedSuperset(minifiedValue, expandedValue, keyPath)
+ } else if (Array.isArray(minifiedValue)) {
+ expect(expandedValue, `expected array at ${keyPath}`).to.be.an('array')
+ expect(expandedValue).to.deep.equal(minifiedValue)
+ } else {
+ expect(expandedValue, `value mismatch at ${keyPath}`).to.equal(minifiedValue)
+ }
+ }
+}
+
+describe('old JSON serialization', () => {
+ let bookLibraryItemId
+ let podcastLibraryItemId
+
+ beforeEach(async () => {
+ global.ServerSettings = {}
+ Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
+ Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
+ await Database.buildModels()
+
+ const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' })
+ const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id })
+
+ const book = await Database.bookModel.create({
+ title: 'Test Book',
+ audioFiles: [{ index: 1, ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3', size: 1000 } }],
+ tags: ['fiction'],
+ narrators: ['Narrator One'],
+ genres: ['Fantasy'],
+ chapters: [{ id: 0, start: 0, end: 100, title: 'Chapter 1' }],
+ ebookFile: { ino: '2', metadata: { filename: 'book.epub', ext: '.epub', path: '/book.epub', relPath: 'book.epub', size: 500 }, ebookFormat: 'epub' }
+ })
+ const bookLibraryItem = await Database.libraryItemModel.create({
+ libraryFiles: [{ ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3' } }],
+ mediaId: book.id,
+ mediaType: 'book',
+ libraryId: bookLibrary.id,
+ libraryFolderId: bookLibraryFolder.id
+ })
+ bookLibraryItemId = bookLibraryItem.id
+
+ const author = await Database.authorModel.create({ name: 'Test Author', libraryId: bookLibrary.id })
+ await Database.bookAuthorModel.create({ bookId: book.id, authorId: author.id })
+
+ const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: bookLibrary.id })
+ await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id, sequence: '2' })
+
+ const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' })
+ const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id })
+
+ const podcast = await Database.podcastModel.create({
+ title: 'Test Podcast',
+ tags: ['news'],
+ genres: ['Technology'],
+ autoDownloadEpisodes: false
+ })
+ const podcastLibraryItem = await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: podcast.id,
+ mediaType: 'podcast',
+ libraryId: podcastLibrary.id,
+ libraryFolderId: podcastLibraryFolder.id
+ })
+ podcastLibraryItemId = podcastLibraryItem.id
+
+ await Database.podcastEpisodeModel.create({
+ podcastId: podcast.id,
+ title: 'Episode 1',
+ index: 1,
+ audioFile: { ino: '3', metadata: { filename: 'ep1.mp3', ext: '.mp3', path: '/ep1.mp3', relPath: 'ep1.mp3' } }
+ })
+ })
+
+ afterEach(async () => {
+ await Database.sequelize.sync({ force: true })
+ })
+
+ describe('Book', () => {
+ it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => {
+ const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId)
+ const minified = libraryItem.media.toOldJSONMinified()
+ const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id)
+
+ assertExpandedSuperset(minified, expanded)
+
+ expect(expanded).to.have.property('libraryItemId', libraryItem.id)
+ expect(expanded).to.have.property('audioFiles')
+ expect(expanded).to.have.property('chapters')
+ expect(expanded).to.have.property('tracks')
+ expect(expanded.numTracks).to.equal(expanded.tracks.length)
+ expect(expanded.numAudioFiles).to.equal(expanded.audioFiles.length)
+ expect(expanded.numChapters).to.equal(expanded.chapters.length)
+ expect(expanded.ebookFormat).to.equal('epub')
+ })
+ })
+
+ describe('Podcast', () => {
+ it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => {
+ const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId)
+ const minified = libraryItem.media.toOldJSONMinified()
+ const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id)
+
+ assertExpandedSuperset(minified, expanded)
+
+ expect(expanded).to.have.property('libraryItemId', libraryItem.id)
+ expect(expanded).to.have.property('episodes')
+ expect(expanded.numEpisodes).to.equal(expanded.episodes.length)
+ })
+ })
+
+ describe('LibraryItem', () => {
+ it('book library item expanded is a strict superset of minified', async () => {
+ const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId)
+ const minified = libraryItem.toOldJSONMinified()
+ const expanded = libraryItem.toOldJSONExpanded()
+
+ assertExpandedSuperset(minified, expanded)
+
+ expect(expanded).to.have.property('libraryFiles')
+ expect(expanded).to.have.property('lastScan')
+ expect(expanded.numFiles).to.equal(expanded.libraryFiles.length)
+ expect(expanded.media.numTracks).to.equal(expanded.media.tracks.length)
+ })
+
+ it('podcast library item expanded is a strict superset of minified', async () => {
+ const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId)
+ const minified = libraryItem.toOldJSONMinified()
+ const expanded = libraryItem.toOldJSONExpanded()
+
+ assertExpandedSuperset(minified, expanded)
+
+ expect(expanded).to.have.property('libraryFiles')
+ expect(expanded.media.numEpisodes).to.equal(expanded.media.episodes.length)
+ })
+ })
+})
diff --git a/test/server/utils/parsers/parseNfoMetadata.test.js b/test/server/utils/parsers/parseNfoMetadata.test.js
index 9ff51fbe5..ba11ddff2 100644
--- a/test/server/utils/parsers/parseNfoMetadata.test.js
+++ b/test/server/utils/parsers/parseNfoMetadata.test.js
@@ -21,6 +21,20 @@ describe('parseNfoMetadata', () => {
expect(result.subtitle).to.equal('A Novel')
})
+ it('does not split title on bare colon without space', () => {
+ const nfoText = 'Title: 10:04'
+ const result = parseNfoMetadata(nfoText)
+ expect(result.title).to.equal('10:04')
+ expect(result.subtitle).to.be.undefined
+ })
+
+ it('does not split title on colon between words without space', () => {
+ const nfoText = 'Title: Making the Mission:Impossible Movies'
+ const result = parseNfoMetadata(nfoText)
+ expect(result.title).to.equal('Making the Mission:Impossible Movies')
+ expect(result.subtitle).to.be.undefined
+ })
+
it('parses authors', () => {
const nfoText = 'Author: F. Scott Fitzgerald'
const result = parseNfoMetadata(nfoText)
diff --git a/test/server/utils/requestUtils.test.js b/test/server/utils/requestUtils.test.js
new file mode 100644
index 000000000..589c6f1cd
--- /dev/null
+++ b/test/server/utils/requestUtils.test.js
@@ -0,0 +1,40 @@
+const { expect } = require('chai')
+
+const { isRequestSecure, getRequestProtocol, getRequestOrigin } = require('../../../server/utils/requestUtils')
+
+function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) {
+ return {
+ secure,
+ get(header) {
+ if (header === 'host') return host
+ if (header === 'x-forwarded-proto') return xForwardedProto
+ return null
+ }
+ }
+}
+
+describe('requestUtils', () => {
+ it('isRequestSecure uses req.secure', () => {
+ expect(isRequestSecure(mockReq({ secure: true }))).to.equal(true)
+ expect(isRequestSecure(mockReq({ secure: false }))).to.equal(false)
+ })
+
+ it('isRequestSecure uses x-forwarded-proto', () => {
+ expect(isRequestSecure(mockReq({ xForwardedProto: 'https' }))).to.equal(true)
+ expect(isRequestSecure(mockReq({ xForwardedProto: 'http' }))).to.equal(false)
+ expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https' }))).to.equal(true)
+ })
+
+ it('getRequestProtocol returns https or http', () => {
+ expect(getRequestProtocol(mockReq({ secure: true }))).to.equal('https')
+ expect(getRequestProtocol(mockReq())).to.equal('http')
+ })
+
+ it('getRequestOrigin builds origin from protocol and host', () => {
+ expect(getRequestOrigin(mockReq({ secure: true }))).to.deep.equal({
+ protocol: 'https',
+ host: 'books.example.com',
+ origin: 'https://books.example.com'
+ })
+ })
+})