+## ⚠️ Frontend pull requests are not being reviewed or merged for the existing Vue frontend. The frontend is currently being rewritten and migrated to React and should be available soon.
+
# About
Audiobookshelf is a self-hosted audiobook and podcast server.
@@ -57,27 +59,23 @@ 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.
# Installation
-See [install docs](https://www.audiobookshelf.org/docs)
+See [install docs](https://audiobookshelf.org/docs/category/installation)
@@ -87,276 +85,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).
+
+
+# Run from source
+
+This application is built using [NodeJs](https://nodejs.org/).
+
### Dev Container Setup
The easiest way to begin developing this project is to use a dev container. An introduction to dev containers in VSCode can be found [here](https://code.visualstudio.com/docs/devcontainers/containers).
@@ -419,7 +165,7 @@ You are now ready to start development!
### Manual Environment Setup
-If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 20) and [FFmpeg](https://ffmpeg.org/).
+If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 24) and [FFmpeg](https://ffmpeg.org/).
Next you will need to create a `dev.js` file in the project's root directory. This contains configuration information and paths unique to your development environment. You can find an example of this file in `.devcontainer/dev.js`.
@@ -437,7 +183,7 @@ cd ..
After setting up your development environment, either using the dev container or using your own custom environment, the following commands will help you run the server and client.
-To run the server, you can use the command `npm run dev`. This will use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. If you make changes to the server, you will need to restart the server. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
+To run the server, you can use the command `npm run dev`. This will compile the server and use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. Server changes are compiled and restarted automatically. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
You can also build a version of the client that supports live reloading. To do this, start the server, then run the command `(cd client; npm run dev)`. This will run a separate instance of the client at `localhost:3000` that will be automatically updated as you make changes to the client.
@@ -446,7 +192,3 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
- `Debug server`—Run the server.
- `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/Database.js b/server/Database.js
index 213c2c61b..65c8d539c 100644
--- a/server/Database.js
+++ b/server/Database.js
@@ -102,11 +102,6 @@ class Database {
return this.models.libraryItem
}
- /** @type {typeof import('./models/PodcastEpisode')} */
- get podcastEpisodeModel() {
- return this.models.podcastEpisode
- }
-
/** @type {typeof import('./models/MediaProgress')} */
get mediaProgressModel() {
return this.models.mediaProgress
@@ -514,19 +509,17 @@ class Database {
})
}
- replaceNarratorInFilterData(oldNarrator, newNarrator) {
- for (const libraryId in this.libraryFilterData) {
- const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)
- if (indexOf >= 0) {
- this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
- }
+ replaceNarratorInFilterData(libraryId, oldNarrator, newNarrator) {
+ if (!this.libraryFilterData[libraryId]) return
+ const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)
+ if (indexOf >= 0) {
+ this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
}
}
- removeNarratorFromFilterData(narrator) {
- for (const libraryId in this.libraryFilterData) {
- this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)
- }
+ removeNarratorFromFilterData(libraryId, narrator) {
+ if (!this.libraryFilterData[libraryId]) return
+ this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)
}
addNarratorsToFilterData(libraryId, narrators) {
diff --git a/server/Server.js b/server/Server.js
index d6f748a1e..5fac001d8 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')
@@ -45,6 +46,18 @@ const passport = require('passport')
const expressSession = require('express-session')
const MemoryStore = require('./libs/memorystore')
+/**
+ * `next()` for a custom server is a NextCustomServer wrapper. After prepare(),
+ * `.server` is another NextServer wrapper; the Node server with `nextConfig` is
+ * one more `.server` down. Older react clients have no basePath and must keep
+ * Express-stripped URLs.
+ */
+function getPreparedNextBasePath(nextApp) {
+ const nodeServer = nextApp.server?.server
+ const basePath = nodeServer?.nextConfig?.basePath
+ return typeof basePath === 'string' ? basePath : ''
+}
+
class Server {
constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT
@@ -157,6 +170,12 @@ class Server {
}
await Database.init(false)
+
+ if (typeof global.RouterBasePath !== 'string') {
+ throw new Error('[Server] RouterBasePath must be a string before serving requests')
+ }
+ Logger.info(`[Server] Serving from base path "${global.RouterBasePath || '/'}"`)
+
// Create or set JWT secret in token manager
await this.auth.tokenManager.initTokenSecret()
@@ -288,10 +307,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 +320,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',
@@ -407,7 +427,25 @@ class Server {
const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })
const handle = nextApp.getRequestHandler()
await nextApp.prepare()
- router.all('*', (req, res) => handle(req, res))
+ const nextBasePath = getPreparedNextBasePath(nextApp)
+ if (nextBasePath) {
+ Logger.info(`[Server] Next client basePath is "${nextBasePath}"`)
+ } else {
+ Logger.info(`[Server] Next client has no basePath; leaving Express-stripped URLs as-is`)
+ }
+ router.all('*', (req, res) => {
+ // Next is configured with the same base path as this router, but Express strips the mount
+ // prefix from req.url, so put it back. The bare mount path is passed through without a
+ // trailing slash, which Next would otherwise redirect away from on every request.
+ // Skip when Next has no basePath (older client on master).
+ if (nextBasePath && req.baseUrl) {
+ const queryIndex = req.url.indexOf('?')
+ const pathname = queryIndex === -1 ? req.url : req.url.slice(0, queryIndex)
+ const search = queryIndex === -1 ? '' : req.url.slice(queryIndex)
+ req.url = `${req.baseUrl}${pathname === '/' ? '' : pathname}${search}`
+ }
+ handle(req, res)
+ })
}
const unixSocketPrefix = 'unix/'
diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js
index e47a3408a..97b5aea06 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
@@ -282,7 +302,7 @@ class SocketAuthority {
}
} else {
// JWT based authentication
- if (!token_data.userId) {
+ if (!TokenManager.isBearerAccessTokenPayload(token_data)) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('auth_failed', { message: 'Invalid token' })
}
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 0778932d7..aa1da6882 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 */
@@ -15,6 +17,8 @@ class TokenManager {
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) || 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
@@ -115,6 +134,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
+ jti: uuid.v4(),
type: 'access'
}
const options = {
@@ -138,6 +158,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
+ jti: uuid.v4(),
type: 'refresh'
}
const options = {
@@ -183,20 +204,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 +295,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 +359,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 +404,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 +462,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 +488,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 +503,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 +536,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..85af70df5 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)
}
@@ -426,6 +427,7 @@ class AuthorController {
async middleware(req, res, next) {
const author = await Database.authorModel.findByPk(req.params.id)
if (!author) return res.sendStatus(404)
+ if (!req.user.checkCanAccessLibrary(author.libraryId)) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
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 55ef45690..6bf36a7b4 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -462,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) {
@@ -563,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
@@ -714,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) {
@@ -782,7 +782,7 @@ class LibraryController {
.filter((v) => !!v)
const series = await Database.seriesModel.findByPk(req.params.seriesId)
- if (!series) return res.sendStatus(404)
+ if (!series || series.libraryId !== req.library.id) return res.sendStatus(404)
const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
@@ -1167,11 +1167,11 @@ class LibraryController {
}
// Update filter data
- Database.replaceNarratorInFilterData(narratorName, updatedName)
+ Database.replaceNarratorInFilterData(req.library.id, narratorName, updatedName)
const itemsUpdated = []
- const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
+ const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName], req.library.id)
for (const libraryItem of itemsWithNarrator) {
libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
@@ -1211,11 +1211,11 @@ class LibraryController {
const narratorName = libraryFilters.decode(req.params.narratorId)
// Update filter data
- Database.removeNarratorFromFilterData(narratorName)
+ Database.removeNarratorFromFilterData(req.library.id, narratorName)
const itemsUpdated = []
- const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
+ const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName], req.library.id)
for (const libraryItem of itemsWithNarrator) {
libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
@@ -1432,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..18b1dc266 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
@@ -311,6 +335,9 @@ class LibraryItemController {
/**
* PATCH: /api/items/:id/cover
*
+ * This is used when updating the cover to a local image library file for the library item
+ * TODO: In the next API this should be using an inode or similar instead of a file path
+ *
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
@@ -319,6 +346,7 @@ class LibraryItemController {
return res.status(400).send('Invalid request no cover path')
}
+ // Must be a cover file path inside the libraryFiles array
const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
if (validationResult.error) {
return res.status(500).send(validationResult.error)
@@ -398,8 +426,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 +575,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 +599,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 +615,7 @@ class LibraryItemController {
}
await Database.resetLibraryIssuesFilterData(libraryId)
+
res.sendStatus(200)
}
@@ -593,6 +628,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 +655,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 +669,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 +745,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..ca600d373 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 { toNumber, isNullOrNaN, isUUID } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
+const parseUserAgent = require('../utils/parsers/parseUserAgent')
/**
* @typedef RequestUserObject
@@ -26,6 +28,124 @@ class MeController {
res.json(req.user.toOldJSONForBrowser())
}
+ /**
+ * GET: /api/me/sessions
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async getSessions(req, res) {
+ const page = Math.max(0, toNumber(req.query.page, 0))
+ const itemsPerPage = Math.max(1, toNumber(req.query.itemsPerPage, 10))
+
+ if (req.user.isGuest) {
+ return res.json({ sessions: [], total: 0, numPages: 0, page, itemsPerPage })
+ }
+
+ const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
+ const { rows, count } = await Database.sessionModel.findAndCountAll({
+ where: {
+ userId: req.user.id,
+ expiresAt: { [Op.gt]: new Date() }
+ },
+ order: [['updatedAt', 'DESC']],
+ limit: itemsPerPage,
+ offset: itemsPerPage * page
+ })
+
+ res.json({
+ total: count,
+ numPages: Math.ceil(count / itemsPerPage),
+ page,
+ itemsPerPage,
+ sessions: rows.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)
+ }))
+ })
+ }
+
+ /**
+ * DELETE: /api/me/sessions/:id
+ *
+ * @param {RequestWithUser} req
+ * @param {Response} res
+ */
+ async deleteSession(req, res) {
+ if (req.user.isGuest) {
+ return res.sendStatus(403)
+ }
+
+ if (!isUUID(req.params.id)) {
+ return res.sendStatus(400)
+ }
+
+ const session = await Database.sessionModel.findOne({
+ where: {
+ id: req.params.id,
+ userId: req.user.id
+ }
+ })
+
+ if (!session) {
+ return res.sendStatus(404)
+ }
+
+ await Database.sessionModel.destroy({ where: { id: session.id } })
+ Logger.info(`[MeController] User ${req.user.username} deleted auth session ${session.id}`)
+
+ res.sendStatus(200)
+ }
+
+ /**
+ * 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 +183,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 +191,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 +251,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 +325,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 +358,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 +396,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 +428,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 +452,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 490cb27d2..7fcf935d7 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -8,9 +8,11 @@ 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 ServerSettings = require('../objects/settings/ServerSettings')
const { sanitizeFilename } = require('../utils/fileUtils')
+const { sanitize } = require('../utils/htmlSanitizer')
const TaskManager = require('../managers/TaskManager')
const adminStats = require('../utils/queries/adminStats')
@@ -78,7 +80,12 @@ class MiscController {
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
- await fs.ensureDir(outputDirectory)
+ try {
+ await fs.ensureDir(outputDirectory)
+ } catch (error) {
+ Logger.error('[MiscController] Failed to create upload directory', outputDirectory, error)
+ return res.sendStatus(500)
+ }
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
@@ -142,16 +149,23 @@ 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)) {
+ const filteredUpdate = {}
+ for (const key in settingsUpdate) {
+ if (ServerSettings.patchableSettingsKeys.has(key)) {
+ filteredUpdate[key] = settingsUpdate[key]
+ }
+ }
+
+ if (filteredUpdate.allowedOrigins && !Array.isArray(filteredUpdate.allowedOrigins)) {
return res.status(400).send('allowedOrigins must be an array')
}
- const madeUpdates = Database.serverSettings.update(settingsUpdate)
+ const madeUpdates = Database.serverSettings.update(filteredUpdate)
if (madeUpdates) {
await Database.updateServerSettings()
// If backup schedule is updated - update backup manager
- if (settingsUpdate.backupSchedule !== undefined) {
+ if (filteredUpdate.backupSchedule !== undefined) {
this.backupManager.updateCronSchedule()
}
}
@@ -605,13 +619,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)
}
/**
@@ -700,6 +712,7 @@ class MiscController {
}
let updatedValue = settingsUpdate[key]
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
+ if (key === 'authLoginCustomMessage' && updatedValue) updatedValue = sanitize(updatedValue) || null
let currentValue = currentAuthenticationSettings[key]
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
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/RSSFeedController.js b/server/controllers/RSSFeedController.js
index e00164219..32279f9ce 100644
--- a/server/controllers/RSSFeedController.js
+++ b/server/controllers/RSSFeedController.js
@@ -57,7 +57,7 @@ class RSSFeedController {
}
// Check item has audio tracks
- if (!itemExpanded.hasAudioTracks()) {
+ if (!itemExpanded.hasAudioTracks) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks')
}
diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js
index 3e7ea1deb..bf7991680 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 {
@@ -142,7 +146,7 @@ class ShareController {
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
- return res.status(404)
+ return res.sendStatus(404)
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
@@ -182,7 +186,7 @@ class ShareController {
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
- return res.status(404)
+ return res.sendStatus(404)
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
@@ -227,7 +231,7 @@ class ShareController {
const { slug } = req.params
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
- return res.status(404)
+ return res.sendStatus(404)
}
if (!mediaItemShare.isDownloadable) {
return res.status(403).send('Download is not allowed for this item')
@@ -291,7 +295,7 @@ class ShareController {
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
- return res.status(404)
+ return res.sendStatus(404)
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
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 6d31b2ab9..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
@@ -646,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()
}
diff --git a/server/libs/archiver/archiverUtils/readableStream/internal/streams/lazy_transform.js b/server/libs/archiver/archiverUtils/readableStream/internal/streams/lazy_transform.js
index 06073b0d1..afcb0c071 100644
--- a/server/libs/archiver/archiverUtils/readableStream/internal/streams/lazy_transform.js
+++ b/server/libs/archiver/archiverUtils/readableStream/internal/streams/lazy_transform.js
@@ -7,7 +7,7 @@ const { ObjectDefineProperties, ObjectDefineProperty, ObjectSetPrototypeOf } = r
const stream = require('../../stream')
-const { getDefaultEncoding } = require('../crypto/util')
+const getDefaultEncoding = () => 'buffer'
module.exports = LazyTransform
diff --git a/server/libs/nodeFfprobe/index.js b/server/libs/nodeFfprobe/index.js
index bdab89ad0..5b33db6cf 100644
--- a/server/libs/nodeFfprobe/index.js
+++ b/server/libs/nodeFfprobe/index.js
@@ -15,20 +15,23 @@ module.exports = (function () {
proc.stdout.setEncoding('utf8')
proc.stderr.setEncoding('utf8')
- proc.stdout.on('data', function (data) { probeData.push(data) })
- proc.stderr.on('data', function (data) { errData.push(data) })
+ proc.stdout.on('data', function (data) {
+ probeData.push(data)
+ })
+ proc.stderr.on('data', function (data) {
+ errData.push(data)
+ })
- proc.on('exit', code => { exitCode = code })
- proc.on('error', err => reject(err))
+ proc.on('error', (err) => reject(err))
proc.on('close', () => {
try {
- resolve(JSON.parse(probeData.join('')))
+ resolve(JSON.parse(probeData.join('')))
} catch (err) {
- reject(err);
+ reject(err)
}
})
})
}
return doProbe
-})()
\ No newline at end of file
+})()
diff --git a/server/libs/watcher/types.js b/server/libs/watcher/types.js
index 682b3f45f..cb0db20b6 100644
--- a/server/libs/watcher/types.js
+++ b/server/libs/watcher/types.js
@@ -5,8 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WatcherStats = exports.Stats = void 0;
-const ripstat_1 = require("ripstat");
+const ripstat_1 = require("./ripstat");
Object.defineProperty(exports, "Stats", { enumerable: true, get: function () { return ripstat_1.Stats; } });
const watcher_stats_1 = __importDefault(require("./watcher_stats"));
exports.WatcherStats = watcher_stats_1.default;
-//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
\ No newline at end of file
+//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
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/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js
index 7471a1ca0..dba6826c1 100644
--- a/server/managers/AudioMetadataManager.js
+++ b/server/managers/AudioMetadataManager.js
@@ -272,7 +272,7 @@ class AudioMetadataMangaer {
this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id)
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
- Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
+ Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued.length} tasks queued.`)
const nextTask = this.tasksQueued.shift()
SocketAuthority.emitter('metadata_embed_queue_update', {
libraryItemId: nextTask.data.libraryItemId,
diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js
index 2697f94ea..1be370c8c 100644
--- a/server/managers/BackupManager.js
+++ b/server/managers/BackupManager.js
@@ -112,7 +112,7 @@ class BackupManager {
.mv(tempPath)
.then(() => true)
.catch((error) => {
- Logger.error('[BackupManager] Failed to move backup file', path, error)
+ Logger.error('[BackupManager] Failed to move backup file', tempPath, error)
return false
})
if (!success) {
@@ -126,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')
@@ -140,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)
@@ -257,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/BinaryManager.js b/server/managers/BinaryManager.js
index f16d47c0b..17eefa60e 100644
--- a/server/managers/BinaryManager.js
+++ b/server/managers/BinaryManager.js
@@ -369,8 +369,8 @@ class BinaryManager {
* @param {Binary} binary
*/
async removeBinary(destination, binary) {
+ const binaryPath = path.join(destination, binary.fileName)
try {
- const binaryPath = path.join(destination, binary.fileName)
if (await fs.pathExists(binaryPath)) {
Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
await fs.remove(binaryPath)
diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js
index 38c0415e9..e44f1a78c 100644
--- a/server/managers/CacheManager.js
+++ b/server/managers/CacheManager.js
@@ -4,8 +4,13 @@ const stream = require('stream')
const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers')
const { encodeUriPath } = require('../utils/fileUtils')
+const { isUUID } = require('../utils/index')
const Database = require('../Database')
+function isValidCacheDimension(dimension) {
+ return Number.isSafeInteger(dimension) && dimension > 0
+}
+
class CacheManager {
constructor() {
this.CachePath = null
@@ -40,6 +45,9 @@ class CacheManager {
const width = options.width || 400
const height = options.height || null
+ if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(libraryItemId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
+ return res.sendStatus(400)
+ }
res.type(`image/${format}`)
const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
@@ -149,6 +157,9 @@ class CacheManager {
const width = options.width || 400
const height = options.height || null
+ if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(authorId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
+ return res.sendStatus(400)
+ }
res.type(`image/${format}`)
var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format
diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js
index c8f889108..6233ed16e 100644
--- a/server/managers/CoverManager.js
+++ b/server/managers/CoverManager.js
@@ -138,6 +138,16 @@ class CoverManager {
}
}
coverPath = filePathToPOSIX(coverPath)
+
+ // Must already have been scanned in for the library item
+ const isLibraryItemFile = libraryItem.libraryFiles?.some((lf) => filePathToPOSIX(lf.metadata?.path) === coverPath)
+ if (!isLibraryItemFile) {
+ Logger.error(`[CoverManager] validate cover path is not a library item file "${coverPath}"`)
+ return {
+ error: 'Invalid cover path'
+ }
+ }
+
// Cover path already set on media
if (libraryItem.media.coverPath == coverPath) {
Logger.debug(`[CoverManager] validate cover path already set "${coverPath}"`)
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/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..19923750e 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
@@ -421,21 +422,6 @@ class Book extends Model {
hasUpdates = true
}
- // TODO: Remove support for updating audioFiles, chapters and ebookFile here
- const arrayOfObjectsKeys = ['audioFiles', 'chapters']
- arrayOfObjectsKeys.forEach((key) => {
- if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {
- this[key] = payload[key]
- this.changed(key, true)
- hasUpdates = true
- }
- })
- if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {
- this.ebookFile = payload.ebookFile
- this.changed('ebookFile', true)
- hasUpdates = true
- }
-
if (hasUpdates) {
Logger.debug(`[Book] "${this.title}" changed keys:`, this.changed())
await this.save()
@@ -470,13 +456,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 +632,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 +659,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 +677,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..4876d52e6 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,68 @@ 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`)
@@ -794,8 +848,18 @@ class LibraryItem extends Model {
get isPodcast() {
return this.mediaType === 'podcast'
}
+ /**
+ * Check if book or podcast library item has audio tracks.
+ * Requires expanded library item (media loaded).
+ *
+ * @returns {boolean}
+ */
get hasAudioTracks() {
- return this.media.hasAudioTracks()
+ if (!this.media) {
+ Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
+ return false
+ }
+ return this.media.hasAudioTracks
}
/**
@@ -846,24 +910,6 @@ class LibraryItem extends Model {
}
}
- /**
- * Check if book or podcast library item has audio tracks
- * Requires expanded library item
- *
- * @returns {boolean}
- */
- hasAudioTracks() {
- if (!this.media) {
- Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
- return false
- }
- if (this.isBook) {
- return this.media.audioFiles?.length > 0
- } else {
- return this.media.podcastEpisodes?.length > 0
- }
- }
-
/**
*
* @param {string} ino
@@ -946,6 +992,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 +1025,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 5aa013e8e..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', 'ac3', 'eac3']
+ return ['alac', 'ac3', 'eac3', 'opus']
}
get userToken() {
return this.user.token
diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js
index a03e17c75..0454161ee 100644
--- a/server/objects/settings/ServerSettings.js
+++ b/server/objects/settings/ServerSettings.js
@@ -3,8 +3,33 @@ const packageJson = require('../../../package.json')
const { BookshelfView } = require('../../utils/constants')
const Logger = require('../../Logger')
const User = require('../../models/User')
+const { sanitize } = require('../../utils/htmlSanitizer')
+
+const PATCHABLE_SETTINGS_KEYS = new Set([
+ 'scannerParseSubtitle',
+ 'scannerFindCovers',
+ 'scannerCoverProvider',
+ 'scannerPreferMatchedMetadata',
+ 'scannerDisableWatcher',
+ 'storeCoverWithItem',
+ 'storeMetadataWithItem',
+ 'allowIframe',
+ 'allowedOrigins',
+ 'backupSchedule',
+ 'backupsToKeep',
+ 'maxBackupSize',
+ 'logLevel',
+ 'homeBookshelfView',
+ 'bookshelfView',
+ 'dateFormat',
+ 'timeFormat',
+ 'language',
+ 'chromecastEnabled',
+ 'sortingIgnorePrefix'
+])
class ServerSettings {
+ static patchableSettingsKeys = PATCHABLE_SETTINGS_KEYS
constructor(settings) {
this.id = 'server-settings'
/** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */
@@ -126,7 +151,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 +284,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 +304,7 @@ class ServerSettings {
delete json.authOpenIDMobileRedirectURIs
delete json.authOpenIDGroupClaim
delete json.authOpenIDAdvancedPermsClaim
+ json.timeZone = ServerSettings.getHostTimeZone()
return json
}
@@ -309,7 +347,7 @@ class ServerSettings {
get authFormData() {
const clientFormData = {
- authLoginCustomMessage: this.authLoginCustomMessage
+ authLoginCustomMessage: sanitize(this.authLoginCustomMessage)
}
if (this.authActiveAuthMethods.includes('openid')) {
clientFormData.authOpenIDButtonText = this.authOpenIDButtonText
@@ -327,21 +365,9 @@ class ServerSettings {
update(payload) {
let hasUpdates = false
for (const key in payload) {
- if (key === 'sortingPrefixes') {
- // Sorting prefixes are updated with the /api/sorting-prefixes endpoint
- continue
- } else if (key === 'authActiveAuthMethods') {
- if (!payload[key]?.length) {
- Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])
- continue
- }
- this.authActiveAuthMethods.sort()
- payload[key].sort()
- if (payload[key].join() !== this.authActiveAuthMethods.join()) {
- this.authActiveAuthMethods = payload[key]
- hasUpdates = true
- }
- } else if (this[key] !== payload[key]) {
+ if (!PATCHABLE_SETTINGS_KEYS.has(key)) continue
+
+ if (this[key] !== payload[key]) {
if (key === 'logLevel') {
Logger.setLogLevel(payload[key])
}
diff --git a/server/providers/Audible.js b/server/providers/Audible.js
index 133d3c0d8..a6c8f0aa9 100644
--- a/server/providers/Audible.js
+++ b/server/providers/Audible.js
@@ -83,7 +83,8 @@ class Audible {
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
region: item.region || null,
rating: item.rating || null,
- abridged: formatType === 'abridged'
+ abridged: formatType === 'abridged',
+ explicit: !!item.isAdult
}
}
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index db04bf5ec..7c89126b2 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -171,6 +171,11 @@ 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.delete('/me/sessions/:id', MeController.deleteSession.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))
@@ -363,8 +368,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
@@ -395,7 +401,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..7ccb17a11 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
}
@@ -513,10 +515,7 @@ class LibraryScanner {
attributes: ['id', 'path'],
where: {
path: {
- [sequelize.Op.not]: fullPath
- },
- path: {
- [sequelize.Op.startsWith]: fullPath
+ [sequelize.Op.startsWith]: fullPath + '/'
}
}
})
@@ -529,10 +528,7 @@ class LibraryScanner {
attributes: ['id', 'path'],
where: {
path: {
- [sequelize.Op.not]: altFullPath
- },
- path: {
- [sequelize.Op.startsWith]: altFullPath
+ [sequelize.Op.startsWith]: altFullPath + '/'
}
}
})
diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js
index c9569c3ad..b403a3c9d 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
@@ -57,8 +58,12 @@ class PodcastScanner {
/** @type {AudioFile[]} */
let newAudioFiles = []
+ // Track episode add/remove/update so item_updated includes the current episode list
+ let hasEpisodeChanges = false
+
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
- // Filter out and destroy episodes that were removed
+ // Filter out and destroy episodes that were removed.
+ // filter() returns a new array — reassign to media.podcastEpisodes before emit.
const episodesToRemove = []
existingPodcastEpisodes = existingPodcastEpisodes.filter((ep) => {
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
@@ -69,6 +74,7 @@ class PodcastScanner {
})
if (episodesToRemove.length) {
+ hasEpisodeChanges = true
// Remove episodes from playlists and media progress
const episodeIds = episodesToRemove.map((ep) => ep.id)
await Database.playlistModel.removeMediaItemsFromPlaylists(episodeIds)
@@ -115,6 +121,7 @@ class PodcastScanner {
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(podcastEpisode, libraryScan)
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${podcastEpisode.title}" keys changed [${podcastEpisode.changed()?.join(', ')}]`)
await podcastEpisode.save()
+ hasEpisodeChanges = true
}
}
@@ -154,9 +161,13 @@ class PodcastScanner {
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newPodcastEpisode.title}" added`)
await newPodcastEpisode.save()
existingPodcastEpisodes.push(newPodcastEpisode)
+ hasEpisodeChanges = true
}
}
+ // Keep association in sync for toOldJSONExpanded() / item_updated socket payload
+ media.podcastEpisodes = existingPodcastEpisodes
+
let hasMediaChanges = false
if (existingPodcastEpisodes.length !== media.numEpisodes) {
media.numEpisodes = existingPodcastEpisodes.length
@@ -249,7 +260,7 @@ class PodcastScanner {
return {
libraryItem: existingLibraryItem,
- wasUpdated: hasMediaChanges || libraryItemUpdated
+ wasUpdated: hasMediaChanges || libraryItemUpdated || hasEpisodeChanges
}
}
@@ -398,6 +409,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 +435,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/utils/comicBookExtractors.js b/server/utils/comicBookExtractors.js
index 6fc373921..cbb2940fb 100644
--- a/server/utils/comicBookExtractors.js
+++ b/server/utils/comicBookExtractors.js
@@ -6,6 +6,21 @@ const fs = require('../libs/fsExtra')
const StreamZip = require('../libs/nodeStreamZip')
const Archive = require('../libs/libarchive/archive')
const { isWritable } = require('./fileUtils')
+const { sanitizePath } = require('../libs/archiver/archiverUtils')
+
+/**
+ * Sanitize a path from an archive
+ *
+ * @param {string} filename
+ * @returns {string}
+ */
+function sanitizeArchivePath(filename) {
+ const sanitizedPath = Path.normalize(sanitizePath(filename))
+ if (!sanitizedPath || sanitizedPath === '.' || sanitizedPath === '..' || sanitizedPath.startsWith(`..${Path.sep}`) || Path.isAbsolute(sanitizedPath)) {
+ throw new Error(`[CbrComicBookExtractor] Unsafe archive path "${filename}"`)
+ }
+ return sanitizedPath
+}
class AbstractComicBookExtractor {
constructor(comicPath) {
@@ -57,7 +72,11 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
this.tmpDir = global.MetadataPath ? Path.join(global.MetadataPath, 'tmp') : os.tmpdir()
await fs.ensureDir(this.tmpDir)
if (!(await isWritable(this.tmpDir))) throw new Error(`[CbrComicBookExtractor] Temp directory "${this.tmpDir}" is not writable`)
- this.archive = await unrar.createExtractorFromFile({ filepath: this.comicPath, targetPath: this.tmpDir })
+ this.archive = await unrar.createExtractorFromFile({
+ filepath: this.comicPath,
+ targetPath: this.tmpDir,
+ filenameTransform: sanitizeArchivePath
+ })
Logger.debug(`[CbrComicBookExtractor] Opened comic book "${this.comicPath}". Using temp directory "${this.tmpDir}" for extraction.`)
}
@@ -81,14 +100,22 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
}
}
+ getExtractedFilePath(file) {
+ const sanitizedFile = sanitizeArchivePath(file)
+ return {
+ filePath: Path.join(this.tmpDir, sanitizedFile),
+ relativePath: sanitizedFile
+ }
+ }
+
async extractToBuffer(file) {
if (!this.archive) return null
const extracted = this.archive.extract({ files: [file] })
const files = [...extracted.files]
- const filePath = Path.join(this.tmpDir, files[0].fileHeader.name)
+ const { filePath, relativePath } = this.getExtractedFilePath(files[0].fileHeader.name)
const fileData = await fs.readFile(filePath)
await fs.remove(filePath)
- await this.removeEmptyParentDirs(files[0].fileHeader.name)
+ await this.removeEmptyParentDirs(relativePath)
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${fileData.length}`)
return fileData
}
@@ -97,9 +124,9 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
if (!this.archive) return false
const extracted = this.archive.extract({ files: [file] })
const files = [...extracted.files]
- const extractedFilePath = Path.join(this.tmpDir, files[0].fileHeader.name)
+ const { filePath: extractedFilePath, relativePath } = this.getExtractedFilePath(files[0].fileHeader.name)
await fs.move(extractedFilePath, outputFilePath, { overwrite: true })
- await this.removeEmptyParentDirs(files[0].fileHeader.name)
+ await this.removeEmptyParentDirs(relativePath)
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`)
return true
}
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 c7700a783..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) => {
diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js
index 5702071e5..937d72b90 100644
--- a/server/utils/libraryHelpers.js
+++ b/server/utils/libraryHelpers.js
@@ -1,10 +1,13 @@
const { createNewSortInstance } = require('../libs/fastSort')
const Database = require('../Database')
+const Logger = require('../Logger')
const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
+const VALID_COLLAPSE_SUBSERIES_SORTS = new Set(['addedAt', 'size', 'birthtimeMs', 'mtimeMs', 'media.duration', 'media.metadata.publishedYear', 'media.metadata.authorName', 'media.metadata.authorNameLF', 'media.metadata.title', 'sequence', 'progress', 'progress.createdAt', 'progress.finishedAt', 'random'])
+
module.exports = {
/**
*
@@ -100,6 +103,11 @@ module.exports = {
* @returns {Object[]}
*/
async handleCollapseSubseries(payload, seriesId, user, library) {
+ if (payload.sortBy && !VALID_COLLAPSE_SUBSERIES_SORTS.has(payload.sortBy)) {
+ Logger.warn(`[libraryHelpers] Invalid "sort" query string "${payload.sortBy}"`)
+ payload.sortBy = undefined
+ }
+
const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {
include: {
model: Database.bookModel,
@@ -196,7 +204,9 @@ module.exports = {
return li.collapsedSeries?.name || li.media.title
}
} else {
- return payload.sortBy.split('.').reduce((a, b) => a[b], li)
+ if (payload.sortBy === 'media.metadata.authorName') return li.authorNamesFirstLast ?? ''
+ if (payload.sortBy === 'media.metadata.authorNameLF') return li.authorNamesLastFirst ?? ''
+ return payload.sortBy.split('.').reduce((a, b) => a?.[b], li) ?? ''
}
}
})
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 2042a8e39..8ead98505 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -69,7 +69,7 @@ function extractStringOrStringify(json) {
return json[Object.keys(json)[0]][0]
}
// Handles case where html was included without being wrapped in CDATA
- return JSON.stringify(value)
+ return JSON.stringify(json)
} catch {
return ''
}
@@ -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/prober.js b/server/utils/prober.js
index 40a3b5b5c..0971e3632 100644
--- a/server/utils/prober.js
+++ b/server/utils/prober.js
@@ -12,22 +12,22 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
}
// Attempt to get bitrate from bps tags
- var bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
+ const bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
if (bps && !isNaN(bps)) {
return Number(bps)
}
- var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
- var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
+ const tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
+ const tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) {
- var bps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
- if (bps && !isNaN(bps)) {
- return bps
+ const calculatedBps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
+ if (calculatedBps && !isNaN(calculatedBps)) {
+ return calculatedBps
}
}
if (total_bit_rate && stream.codec_type === 'video') {
- var estimated_bit_rate = total_bit_rate
+ let estimated_bit_rate = total_bit_rate
all_streams.forEach((stream) => {
if (stream.bit_rate && !isNaN(stream.bit_rate)) {
estimated_bit_rate -= Number(stream.bit_rate)
@@ -46,9 +46,9 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
}
function tryGrabFrameRate(stream) {
- var avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
+ let avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
if (!avgFrameRate) return null
- var parts = avgFrameRate.split('/')
+ const parts = avgFrameRate.split('/')
if (parts.length === 2) {
avgFrameRate = Number(parts[0]) / Number(parts[1])
} else {
@@ -59,13 +59,13 @@ function tryGrabFrameRate(stream) {
}
function tryGrabSampleRate(stream) {
- var sample_rate = stream.sample_rate
+ const sample_rate = stream.sample_rate
if (!isNaN(sample_rate)) return Number(sample_rate)
return null
}
function tryGrabChannelLayout(stream) {
- var layout = stream.channel_layout
+ const layout = stream.channel_layout
if (!layout) return null
return String(layout).split('(').shift()
}
@@ -81,7 +81,7 @@ function tryGrabTags(stream, ...tags) {
}
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
- var info = {
+ const info = {
index: stream.index,
type: stream.codec_type,
codec: stream.codec_name || null,
@@ -95,7 +95,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
if (stream.tags) info.tags = stream.tags
if (info.type === 'audio' || info.type === 'subtitle') {
- var disposition = stream.disposition || {}
+ const disposition = stream.disposition || {}
info.is_default = disposition.default === 1 || disposition.default === '1'
}
@@ -230,7 +230,7 @@ function parseTags(format, verbose) {
function getDefaultAudioStream(audioStreams) {
if (!audioStreams || !audioStreams.length) return null
if (audioStreams.length === 1) return audioStreams[0]
- var defaultStream = audioStreams.find((a) => a.is_default)
+ const defaultStream = audioStreams.find((a) => a.is_default)
if (!defaultStream) return audioStreams[0]
return defaultStream
}
diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js
index 7312b9d5d..240e33451 100644
--- a/server/utils/queries/libraryFilters.js
+++ b/server/utils/queries/libraryFilters.js
@@ -11,7 +11,12 @@ const naturalSort = createNewSortInstance({
module.exports = {
decode(text) {
- return Buffer.from(decodeURIComponent(text), 'base64').toString()
+ try {
+ return Buffer.from(decodeURIComponent(text), 'base64').toString()
+ } catch (error) {
+ Logger.warn(`[libraryFilters] Failed to decode filter value "${text}": ${error.message}`)
+ return null
+ }
},
/**
diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js
index 7f95d0ecc..a34aebfd7 100644
--- a/server/utils/queries/libraryItemFilters.js
+++ b/server/utils/queries/libraryItemFilters.js
@@ -133,9 +133,10 @@ module.exports = {
/**
* Get all library items that have narrators
* @param {string[]} narrators
+ * @param {string} libraryId
* @returns {Promise}
*/
- async getAllLibraryItemsWithNarrators(narrators) {
+ async getAllLibraryItemsWithNarrators(narrators, libraryId) {
const libraryItems = []
const booksWithGenre = await Database.bookModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), {
@@ -146,7 +147,8 @@ module.exports = {
},
include: [
{
- model: Database.libraryItemModel
+ model: Database.libraryItemModel,
+ where: { libraryId }
},
{
model: Database.authorModel,
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index 7ae1dc866..cac9a7d17 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -309,11 +309,12 @@ module.exports = {
* @param {Sequelize.WhereOptions} seriesWhere
* @returns {object} { booksToExclude, bookSeriesToInclude }
*/
- async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
+ async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere, replacements = {}) {
const allSeries = await Database.seriesModel.findAll({
attributes: ['id', 'name', [Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']],
distinct: true,
subQuery: false,
+ replacements,
where: seriesWhere,
include: [
{
@@ -581,7 +582,7 @@ module.exports = {
...bookIncludes
]
}
- const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere)
+ const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere, replacements)
if (booksToExclude.length) {
bookWhere.push({
id: {
@@ -888,28 +889,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 +970,6 @@ module.exports = {
},
include: libraryItemIncludes
},
- {
- model: Database.mediaProgressModel,
- where: {
- userId: user.id
- },
- required: false
- },
{
model: Database.bookAuthorModel,
attributes: ['authorId'],
@@ -942,14 +987,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/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js
index ed71e5b3f..c8ebb0d77 100644
--- a/server/utils/queries/seriesFilters.js
+++ b/server/utils/queries/seriesFilters.js
@@ -5,7 +5,12 @@ const libraryItemsBookFilters = require('./libraryItemsBookFilters')
module.exports = {
decode(text) {
- return Buffer.from(decodeURIComponent(text), 'base64').toString()
+ try {
+ return Buffer.from(decodeURIComponent(text), 'base64').toString()
+ } catch (error) {
+ Logger.warn(`[seriesFilters] Failed to decode filter value "${text}": ${error.message}`)
+ return null
+ }
},
/**
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/server/utils/scandir.js b/server/utils/scandir.js
index 6dd2d67fe..7148ef06a 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -149,16 +149,16 @@ module.exports.buildLibraryFile = buildLibraryFile
function getBookDataFromDir(relPath, parseSubtitle = false) {
const splitDir = relPath.split('/')
- var folder = splitDir.pop() // Audio files will always be in the directory named for the title
- series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
- author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
+ const titleFolder = splitDir.pop() // Audio files will always be in the directory named for the title
+ const series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
+ const author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
- // The may contain various other pieces of metadata, these functions extract it.
- var [folder, asin] = getASIN(folder)
- var [folder, narrators] = getNarrator(folder)
- var [folder, sequence] = series ? getSequence(folder) : [folder, null]
- var [folder, publishedYear] = getPublishedYear(folder)
- var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
+ // Each extractor strips one piece of metadata and returns the remaining folder name.
+ const [afterAsin, asin] = getASIN(titleFolder)
+ const [afterNarrators, narrators] = getNarrator(afterAsin)
+ const [afterSequence, sequence] = series ? getSequence(afterNarrators) : [afterNarrators, null]
+ const [afterYear, publishedYear] = getPublishedYear(afterSequence)
+ const [title, subtitle] = parseSubtitle ? getSubtitle(afterYear) : [afterYear, null]
return {
title,
@@ -180,8 +180,8 @@ module.exports.getBookDataFromDir = getBookDataFromDir
* @returns {[string, string]} [folder, narrator]
*/
function getNarrator(folder) {
- let pattern = /^(?.*) \{(?.*)\}$/
- let match = folder.match(pattern)
+ const pattern = /^(?.*) \{(?.*)\}$/
+ const match = folder.match(pattern)
return match ? [match.groups.title, match.groups.narrators] : [folder, null]
}
@@ -204,12 +204,12 @@ function getNarrator(folder) {
*/
function getSequence(folder) {
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
- let pattern = /^(?vol\.? |volume |book )?(?\d{0,3}(?:\.\d{1,2})?)(?\.?)(?: (?.*))?$/i
+ const pattern = /^(?vol\.? |volume |book )?(?\d{0,3}(?:\.\d{1,2})?)(?\.?)(?: (?.*))?$/i
let volumeNumber = null
- let parts = folder.split(' - ')
+ const parts = folder.split(' - ')
for (let i = 0; i < parts.length; i++) {
- let match = parts[i].match(pattern)
+ const match = parts[i].match(pattern)
// This excludes '101 Dalmations' but includes '101. Dalmations'
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()
@@ -232,10 +232,10 @@ function getSequence(folder) {
* @returns {[string, string]} [folder, publishedYear]
*/
function getPublishedYear(folder) {
- var publishedYear = null
+ let publishedYear = null
- pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
- var match = folder.match(pattern)
+ const pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
+ const match = folder.match(pattern)
if (match) {
publishedYear = match[1]
folder = match[2]
@@ -252,7 +252,7 @@ function getPublishedYear(folder) {
*/
function getSubtitle(folder) {
// Subtitle is everything after " - "
- var splitTitle = folder.split(' - ')
+ const splitTitle = folder.split(' - ')
return [splitTitle.shift(), splitTitle.join(' - ')]
}
@@ -265,7 +265,7 @@ function getSubtitle(folder) {
function getASIN(folder) {
let asin = null
- let pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
+ const pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
const match = folder.match(pattern)
if (match) {
asin = match[1]
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/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js
index af2e9da8f..9ffd649ae 100644
--- a/test/server/managers/MigrationManager.test.js
+++ b/test/server/managers/MigrationManager.test.js
@@ -17,6 +17,7 @@ describe('MigrationManager', () => {
let fsMoveStub
let fsRemoveStub
let fsEnsureDirStub
+ let fsPathExistsStub
let processExitStub
let configPath = '/path/to/config'
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/providers/Audible.test.js b/test/server/providers/Audible.test.js
index 67e85111b..75807c994 100644
--- a/test/server/providers/Audible.test.js
+++ b/test/server/providers/Audible.test.js
@@ -45,4 +45,26 @@ describe('Audible', () => {
expect(result).to.equal('.5')
})
})
+
+ describe('cleanResult', () => {
+ const baseItem = {
+ title: 'Some Book',
+ formatType: 'unabridged'
+ }
+
+ it('should set explicit to true when isAdult is true', () => {
+ const result = audible.cleanResult({ ...baseItem, isAdult: true })
+ expect(result.explicit).to.equal(true)
+ })
+
+ it('should set explicit to false when isAdult is false', () => {
+ const result = audible.cleanResult({ ...baseItem, isAdult: false })
+ expect(result.explicit).to.equal(false)
+ })
+
+ it('should set explicit to false when isAdult is not present', () => {
+ const result = audible.cleanResult({ ...baseItem })
+ expect(result.explicit).to.equal(false)
+ })
+ })
})
\ No newline at end of file
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'
+ })
+ })
+})
diff --git a/tsconfig.server.json b/tsconfig.server.json
new file mode 100644
index 000000000..a3c6ce5c8
--- /dev/null
+++ b/tsconfig.server.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "rootDir": ".",
+ "outDir": "dist-server",
+ "allowJs": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmitOnError": true,
+ "incremental": true,
+ "tsBuildInfoFile": "dist-server/.tsbuildinfo",
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"],
+ "exclude": ["node_modules", "dist-server", "client", "**/*.d.ts"]
+}