Compare commits

...
Author SHA1 Message Date
advplyr c900676be7 User password changes invalidate sessions 2026-07-23 17:21:25 -05:00
advplyrandGitHub a6cde926ee Merge pull request #5336 from francisrath/fix-broken-help-links
Update outdated in-app help links to new docs site structure
2026-07-22 18:07:58 -04:00
advplyr e8db14fe33 Update collection help docs link 2026-07-22 17:07:32 -05:00
advplyrandGitHub e9b533637f Merge pull request #5376 from DanielAshley/contrib/refresh-grace-period
Make refresh-token grace period configurable (default 10 min)
2026-07-22 18:01:32 -04:00
advplyr 8b97da500d Remove TokenManager test 2026-07-22 16:58:03 -05:00
advplyrandGitHub 37b42601ef Merge pull request #5363 from Vito0912/feat/newEndpoints
add progress and bookmarks specific endpoints
2026-07-22 17:10:51 -04:00
advplyr 5da4ec51ce Remove MeController test 2026-07-22 16:05:59 -05:00
advplyr 4c65a87d5c Update bookmarks endpoints to use json object as root 2026-07-22 16:05:37 -05:00
0e5618dcd8 Apply suggestions from code review
Co-authored-by: advplyr <dev@advplyr.com>
2026-07-22 10:20:41 +02:00
advplyrandGitHub d063d851e1 Merge pull request #5388 from mikiher/fix/bulk-download-permissions
Enforce per-item access checks on bulk library download
2026-07-21 18:34:13 -04:00
advplyrandGitHub 6458e6d3d2 Merge pull request #5387 from mikiher/fix/refresh-token-auth
Reject refresh tokens on API and WebSocket authentication
2026-07-21 18:20:57 -04:00
advplyr 70953d8c82 Slim down TokenManager test 2026-07-21 17:18:58 -05:00
advplyrandGitHub 2d9f63963e Merge pull request #5370 from mikiher/fix/root-user-delete-protection
Prevent admin users from deleting the root account
2026-07-20 18:35:32 -04:00
advplyr f12abca3fc Simplify user delete checks 2026-07-20 17:33:15 -05:00
advplyr bced308462 Trim down UserController test 2026-07-20 17:31:03 -05:00
advplyrandGitHub 528d03fef3 Merge pull request #5386 from mikiher/fix/oidc-url-validation
Fix oidc url validation
2026-07-20 18:08:51 -04:00
advplyr b93c3133a9 Update x-forwarded-proto to check comma separated value 2026-07-20 17:06:21 -05:00
mikiher 8d9db8d103 fix OidcAuthStrategy test module load order 2026-07-20 18:43:15 +03:00
advplyr 08bbf9cd9d Update server settings to include host timezone for use in cron scheduler next run dates 2026-07-19 17:31:00 -05:00
Daniel 95af4812cd Make the refresh-token grace period configurable (default 10 min)
When /auth/refresh rotates the refresh token, the previous token is kept
valid for a short grace period so a client that never received the
rotation response (e.g. a dropped or suspended mobile request) can retry
with the old token and get back the already-rotated current token
instead of a 401.

That window was hardcoded to 60 seconds, which is too short for real
mobile conditions: iOS backgrounding, VPN/proxy timeouts, or network
handoff can delay the retry past a minute, permanently locking the
session out (#5281).

Make it configurable via REFRESH_TOKEN_GRACE_PERIOD (seconds), following
the existing REFRESH_TOKEN_EXPIRY / ACCESS_TOKEN_EXPIRY pattern, and
raise the default to 10 minutes. Adds unit tests for the new config.

Fixes #5281
2026-07-18 21:42:06 -04:00
mikiher 8ec547e302 fix(auth): refresh tokens authentication in API and WebSocket requests 2026-07-16 18:58:44 +03:00
mikiher 32e4da8c92 fix(permissions): enforce per-item access on bulk library download 2026-07-16 18:11:19 +03:00
mikiher 0ce37004af fix(auth): harden OIDC web callback URL validation 2026-07-16 17:11:11 +03:00
mikiher 527745b54e refactor(server): centralize request HTTPS and origin detection 2026-07-16 17:10:42 +03:00
mikiher 3c016314e6 fix(auth): prevent admin users from deleting the root account 2026-07-15 20:34:02 +03:00
advplyrandGitHub 59c7d0275c Merge pull request #5354 from mikiher/scan-author-events
Emit author socket events when book scans create or link authors
2026-07-13 19:27:31 -04:00
Finn Dittmar 4883d0f60c add progress and bookmarks specific endpoints 2026-07-13 11:41:49 +02:00
mikiher 9d9ab49664 Batch author numBooks updates at scan end via authors_num_books_updated 2026-07-10 17:37:31 +03:00
advplyrandGitHub 82aec5f60c Merge pull request #5341 from mikiher/expanded-minified-superset
Add all minified fields to expanded library item JSON
2026-07-09 16:56:45 -05:00
mikiher 7ee5e1bdf6 Emit author socket events when book scans create or link authors 2026-07-09 10:35:18 +03:00
mikiher 3bb53d939d Add all minified fields to expanded library item JSON so socket updates remain a strict superset of shelf payloads 2026-07-01 22:30:21 +03:00
advplyrandGitHub 6f03467f35 Update links in the README 2026-06-29 16:46:33 -05:00
advplyrandGitHub b2f593f1c4 Update readme organizing docs link & remove api docs link 2026-06-29 16:43:24 -05:00
advplyrandGitHub 58dcda36a5 Cleanup readme and link to contributing docs 2026-06-29 16:37:54 -05:00
advplyrandGitHub 354e0bf793 Update readme remove reverse proxy set up and link to docs page 2026-06-29 16:32:41 -05:00
Francis Rath 0784373b61 Update outdated in-app help links to new docs site structure
The help (?) links throughout the client pointed to the old
www.audiobookshelf.org/guides/* pages, which no longer exist and
resolve to a 404. Update all of them to the current
audiobookshelf.org/docs/documentation/* locations.
2026-06-29 03:02:19 +02:00
advplyrandGitHub 2f219ea3cc Merge pull request #5325 from mikiher/file-upload-internal-api
Let next.js handle file uploads through internal-api routes
2026-06-25 17:03:34 -05:00
mikiher 56e60b8420 Server.js: Let next.js handle file uploads through internal-api routes 2026-06-23 14:08:16 +03:00
advplyrandGitHub 9b92b5de34 Merge pull request #5318 from mikiher/match-update-episode-enclosure
Enhance PodcastController to handle enclosure updates
2026-06-20 17:06:32 -05:00
mikiher 3417c0c721 Enhance PodcastController to handle enclosure updates, allowing for null values and object structure validation for enclosure properties. 2026-06-18 12:39:50 +03:00
advplyrandGitHub cbda0360aa Merge pull request #5291 from mikiher/allowed-dev-origins
Read AllowedDevOrigins from dev.js into ALLOWED_DEV_ORIGINS env var
2026-06-04 15:13:34 -05:00
mikiher 036bc081f0 index.js: Read AllowedDevOrigins from dev,js into ALLOWED_DEV_ORIGINS env var 2026-06-04 14:03:59 +03:00
advplyr e70e4b9d40 Fix typo on onTest notification body 2026-05-30 15:43:50 -05:00
advplyr aacdcc47ec Version bump v2.35.1 2026-05-28 15:22:55 -05:00
advplyr 499b52b4dd Update Sequelize where query for User username/email case insensitive 2026-05-28 14:49:49 -05:00
advplyr 1bad2d9072 Cleanup abmetadata file parsing & fix server crash #5268 #4287 #5142 2026-05-27 17:33:14 -05:00
advplyrandGitHub c009db9f28 Merge pull request #5256 from nichwall/fix-bookauthor-collision-on-rename
Fix duplicate bookAuthor creation when renaming authors
2026-05-22 15:43:13 -05:00
advplyrandGitHub 325469c5a5 Merge pull request #5255 from nichwall/refresh-token-uniqueness
Add unique UUID to access and refresh tokens
2026-05-22 15:39:01 -05:00
Nicholas Wallace c97b36e11c Add ignoreDuplicates for bookAuthor when renaming to respect unique index 2026-05-21 21:06:17 -07:00
Nicholas Wallace e944b2a2f5 Add unique UUID to access and refresh tokens 2026-05-21 17:08:39 -07:00
advplyr 2d0a5462d2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2026-05-17 14:31:45 -05:00
advplyr 72dc75482f Version bump v2.35.0 2026-05-17 14:31:41 -05:00
advplyrandGitHub cac74f3477 Merge pull request #5004 from nichwall/token_refresh_race_condition
Access token refresh grace period
2026-05-17 14:16:58 -05:00
advplyrandGitHub 1ad11b2b9e Merge pull request #5216 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-05-17 14:16:24 -05:00
Pavel MiniutkaandHosted Weblate 50eeca2e0f Translated using Weblate (Belarusian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2026-05-15 18:13:27 +00:00
EteranlKandHosted Weblate 4f21fc023c Translated using Weblate (Arabic)
Currently translated at 96.3% (1120 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/
2026-05-15 18:13:26 +00:00
advplyrandHosted Weblate 52a485d135 Added translation using Weblate (Latvian) 2026-05-15 18:13:25 +00:00
d0nizamandHosted Weblate 3b025076e8 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/
2026-05-15 18:13:23 +00:00
Mateusz LesiakandHosted Weblate 6d5d89429d Translated using Weblate (Polish)
Currently translated at 99.8% (1161 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2026-05-15 18:13:22 +00:00
advplyr c010f0e1eb Fix android device sdkVersion not handling it using number type, causing android session device names to show as iOS 2026-05-15 13:13:14 -05:00
advplyr eee377e081 Cleanup TokenManager logs 2026-05-13 16:23:26 -05:00
advplyr b0aaa24660 Update socket events to check client is admin & validate log level 2026-05-12 16:57:28 -05:00
advplyr 47ea6b5092 Update book/podcast scanner to sanitize description pulled from metadata 2026-05-05 17:18:49 -05:00
advplyrandGitHub 4b060febc2 Merge pull request #5221 from brandonfhall/fix/rss-feed-m4b-content-type
Fix: RSS feed serves m4b files with correct Content-Type: audio/mp4
2026-05-03 14:40:43 -05:00
Brandon 40869bcf39 fix: set correct Content-Type for RSS feed audio files
Express's mime package does not recognize .m4b, causing it to fall back
to application/octet-stream. This reuses the existing
getAudioMimeTypeFromExtname utility (already applied to the download
endpoint) to set the correct audio/mp4 header before sendFile.

Fixes #5041
2026-05-02 22:13:35 -04:00
advplyr 3942805129 Cleanup rotateTokensForSession 2026-04-30 16:25:43 -05:00
advplyr dc446862c1 Rename migration to v2.35.0 & merge master 2026-04-30 16:08:24 -05:00
advplyr 379f6c716a Merge branch 'master' into token_refresh_race_condition 2026-04-30 15:59:22 -05:00
advplyr 47457ee1e7 Version bump v2.34.0 2026-04-27 16:51:34 -05:00
advplyrandGitHub cb6ff9eedf Merge pull request #5204 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-04-27 16:45:57 -05:00
LvanAlphenandHosted Weblate 5dc01261c1 Translated using Weblate (Dutch)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/
2026-04-26 21:51:49 +00:00
Naoto IshikawaandHosted Weblate cbc103cf05 Translated using Weblate (Japanese)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ja/
2026-04-26 21:51:49 +00:00
Pavel MiniutkaandHosted Weblate e79256d0fb Translated using Weblate (Belarusian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2026-04-26 21:51:48 +00:00
ugyesandHosted Weblate f8ef56c6bc Translated using Weblate (Hungarian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2026-04-26 21:51:48 +00:00
advplyr 62d7097e23 Add ApiCacheManager test for should remove recent-episodes cache entries 2026-04-26 16:51:39 -05:00
advplyr 92df92ec99 Fix recent episodes endpoint cache not being cleared when updating media progress #5159 2026-04-26 16:51:08 -05:00
advplyrandGitHub 1c229e0627 Merge pull request #5211 from na3shkw/add-i18n-japanese
Add Japanese (ja) language and Japan podcast search region
2026-04-26 16:19:37 -05:00
advplyrandGitHub f8a71cc514 Merge pull request #5089 from meek2100/pass_managers
feat: add autocomplete attributes for password manager support
2026-04-26 16:16:42 -05:00
Naoto IshikawaandGitHub 63de5bb2d5 Merge branch 'advplyr:master' into add-i18n-japanese 2026-04-26 15:23:22 +09:00
advplyrandGitHub 2c3108a1fa Merge pull request #5163 from pjkottke/master
The timestamp in the share URL should override the saved position for the user.
2026-04-25 17:15:23 -05:00
advplyr 928051744a ShareController check ?t param is less than duration, revert frontend mounted usage of param 2026-04-25 17:13:22 -05:00
advplyr 3ccdcaec1a Implement SSRF filter for podcast episode downloads 2026-04-25 16:46:54 -05:00
na3shkw f47bbc7886 Add Japanese language and Japan podcast search region 2026-04-25 15:56:16 +00:00
advplyr 7c0ca44727 Update podcast create/update endpoints to validate autoDownloadSchedule cron expression, validate cron expression before starting in CronManager 2026-04-24 16:55:42 -05:00
advplyr d6a2e5596b Fix undefined variable in error log for when podcast cron is invalid 2026-04-24 16:18:56 -05:00
advplyr a5362de9cc Update podcast createFromRequest to sanitize html description 2026-04-23 14:34:59 -05:00
advplyr 9ab35ef418 Update playlist endpoints to check user still has library access 2026-04-22 16:42:58 -05:00
advplyr 79cc9765cf Update collection endpoints to check user library access 2026-04-22 16:29:47 -05:00
advplyr 5b2a788cfc Update LibraryItemController test with 403 tests 2026-04-21 17:13:52 -05:00
advplyr 80b39abaa2 Update library item batch api endpoints check users per-item access & return 403 2026-04-21 17:13:06 -05:00
peter.kottke 5a6b3d8e61 updates to allow share t argument to over-ride server stored position 2026-04-01 21:05:48 -04:00
meek2100 a9e12657f5 Add autocomplete attributes to login and setup fields for password manager support 2026-02-26 14:29:28 -08:00
Nicholas Wallace cfeb6bd502 Fix: grace period enable statement 2026-01-24 18:57:40 -07:00
Nicholas Wallace 077b523bd6 Fix JS Doc deletion 2026-01-24 18:42:50 -07:00
Nicholas Wallace b8a2d113f0 Allow rotation without grace period for invalidating all user sessions 2026-01-24 18:26:11 -07:00
Nicholas Wallace e1ae4f2d31 Fix: race condition in rotation 2026-01-24 18:10:38 -07:00
Nicholas Wallace 7aa2f84daa Revert default token expiry 2026-01-24 17:00:07 -07:00
Nicholas Wallace da0a64daed Add: 10 second grace period to access token cycle 2026-01-24 16:57:25 -07:00
81 changed files with 2753 additions and 1192 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
{{ emptyMessageHelp }}
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div class="absolute top-0 right-0 p-4">
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#media-item-shares" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
@@ -25,7 +25,7 @@
<div class="text-sm flex items-center justify-center text-gray-200">
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
@@ -158,6 +158,8 @@ export default {
this.isProcessing = true
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
console.error('Failed to update', error)
const errorMessage = typeof error?.response?.data === 'string' ? error?.response?.data : null
this.$toast.error(errorMessage || this.$strings.ToastFailedToUpdate)
return false
})
this.isProcessing = false
@@ -8,7 +8,7 @@
<div class="flex items-center justify-between md:justify-start mb-4">
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/book-metadata" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5">help_outline</span>
</a>
</ui-tooltip>
@@ -25,7 +25,7 @@
<div class="text-sm flex items-center justify-center text-gray-200">
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+3 -2
View File
@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative">
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" :autocomplete="autocomplete" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
@@ -41,7 +41,8 @@ export default {
step: [String, Number],
min: [String, Number],
customInputClass: String,
trimWhitespace: Boolean
trimWhitespace: Boolean,
autocomplete: String
},
data() {
return {
+3 -2
View File
@@ -6,7 +6,7 @@
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label>
</slot>
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" :autocomplete="autocomplete" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
</div>
</template>
@@ -26,7 +26,8 @@ export default {
disabled: Boolean,
inputClass: String,
showCopy: Boolean,
trimWhitespace: Boolean
trimWhitespace: Boolean,
autocomplete: String
},
data() {
return {}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.33.2",
"version": "2.35.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.33.2",
"version": "2.35.1",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.33.2",
"version": "2.35.1",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
+1 -1
View File
@@ -7,7 +7,7 @@
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/api-keys" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -24,7 +24,7 @@
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/oidc-authentication" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/send_to_ereader" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/ebooks" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
@@ -8,7 +8,7 @@
</template>
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/community/community-providers" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderLibraries">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/overview" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderLogs" :description="$strings.MessageLogsDescription">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/server_logs" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/server-logs" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#rss-feed" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -7,7 +7,7 @@
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/user-management" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+5 -5
View File
@@ -17,9 +17,9 @@
<form @submit.prevent="submitServerSetup">
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" autocomplete="username" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
@@ -51,10 +51,10 @@
<form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
<ui-text-input v-model.trim="username" autocomplete="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
<ui-text-input v-model.trim="password" type="password" :disabled="processing" class="w-full mb-3" inputName="password" />
<ui-text-input v-model.trim="password" type="password" autocomplete="current-password" :disabled="processing" class="w-full mb-3" inputName="password" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
</div>
+1
View File
@@ -364,6 +364,7 @@ export default {
}
const startTime = this.playbackSession.currentTime || 0
this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)
this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
+2
View File
@@ -21,6 +21,7 @@ const languageCodeMap = {
he: { label: 'עברית', dateFnsLocale: 'he' },
hr: { label: 'Hrvatski', dateFnsLocale: 'hr' },
it: { label: 'Italiano', dateFnsLocale: 'it' },
ja: { label: '日本語', dateFnsLocale: 'ja' },
lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },
hu: { label: 'Magyar', dateFnsLocale: 'hu' },
ko: { label: '한국어', dateFnsLocale: 'ko' },
@@ -60,6 +61,7 @@ const podcastSearchRegionMap = {
hr: { label: 'Hrvatska' },
il: { label: 'ישראל / إسرائيل' },
it: { label: 'Italia' },
jp: { label: '日本' },
lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' },
hu: { label: 'Magyarország' },
nl: { label: 'Nederland' },
+6
View File
@@ -244,6 +244,8 @@
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
"LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
"LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
"LabelApiKeyUser": "التصرف بالنيابة عن مستخدم",
"LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.",
"LabelApiToken": "رمز API",
"LabelAppend": "إلحاق",
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
@@ -293,6 +295,7 @@
"LabelContinueListening": "استمرار الاستماع",
"LabelContinueReading": "استمرار القراءة",
"LabelContinueSeries": "استمرار المسلسلات",
"LabelCorsAllowed": "CORS Origins مسموح",
"LabelCover": "الغلاف",
"LabelCoverImageURL": "رابط صورة الغلاف",
"LabelCoverProvider": "مزود الغلاف",
@@ -426,6 +429,9 @@
"LabelLibraryFilterSublistEmpty": "لا يوجد {0}",
"LabelLibraryItem": "عنصر المكتبة",
"LabelLibraryName": "اسم المكتبة",
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
"LabelLimit": "حد",
"LabelLineSpacing": "تباعد الأسطر",
"LabelListenAgain": "الاستماع مجدداً",
+10 -10
View File
@@ -1,6 +1,6 @@
{
"ButtonAdd": "Дадаць",
"ButtonAddApiKey": "Дадаць API-ключ",
"ButtonAddApiKey": "Дадаць ключ API",
"ButtonAddChapters": "Дадаць раздзелы",
"ButtonAddDevice": "Дадаць прыладу",
"ButtonAddLibrary": "Дадаць бібліятэку",
@@ -88,7 +88,7 @@
"ButtonResetToDefault": "Скінуць да прадвызначаных",
"ButtonRestore": "Аднавіць",
"ButtonSave": "Захаваць",
"ButtonSaveAndClose": "Захаваць і зачыніць",
"ButtonSaveAndClose": "Захаваць і закрыць",
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
"ButtonScan": "Сканаваць",
"ButtonScanLibrary": "Сканіраваць бібліятэку",
@@ -121,7 +121,7 @@
"HeaderAccount": "Уліковы запіс",
"HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых",
"HeaderAdvanced": "Дадаткова",
"HeaderApiKeys": "API-ключы",
"HeaderApiKeys": "Ключы API",
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
"HeaderAudioTracks": "Аўдыятрэкі",
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
@@ -166,7 +166,7 @@
"HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых",
"HeaderMetadataToEmbed": "Метаданыя для ўбудавання",
"HeaderNewAccount": "Новы ўліковы запіс",
"HeaderNewApiKey": "Новы API-ключ",
"HeaderNewApiKey": "Новы ключ API",
"HeaderNewLibrary": "Новая бібліятэка",
"HeaderNotificationCreate": "Стварыць апавяшчэнне",
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
@@ -212,7 +212,7 @@
"HeaderTableOfContents": "Змест",
"HeaderTools": "Інструменты",
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
"HeaderUpdateApiKey": "Абнавіць API-ключ",
"HeaderUpdateApiKey": "Абнавіць ключ API",
"HeaderUpdateAuthor": "Абнавіць аўтара",
"HeaderUpdateDetails": "Абнавіць падрабязнасці",
"HeaderUpdateLibrary": "Абнавіць бібліятэку",
@@ -242,10 +242,10 @@
"LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей",
"LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей",
"LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы",
"LabelApiKeyCreated": "API-ключ \"{0}\" паспяхова створаны.",
"LabelApiKeyCreatedDescription": "Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.",
"LabelApiKeyCreated": "Ключ API \"{0}\" паспяхова створаны.",
"LabelApiKeyCreatedDescription": "Абавязкова скапіюйце ключ API зараз, бо паўторна яго ўбачыць не атрымаецца.",
"LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка",
"LabelApiKeyUserDescription": "Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
"LabelApiKeyUserDescription": "Гэты ключ API будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
"LabelApiToken": "Токен API",
"LabelAppend": "Дадаць",
"LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)",
@@ -284,7 +284,7 @@
"LabelChaptersFound": "раздзелаў знойдзена",
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
"LabelClosePlayer": "Зачыніць прайгравальнік",
"LabelClosePlayer": "Закрыць прайгравальнік",
"LabelCodec": "Кодэк",
"LabelCollapseSeries": "Згарнуць серыі",
"LabelCollapseSubSeries": "Згарнуць падсерыі",
@@ -884,7 +884,7 @@
"MessageRemoveEpisodes": "Выдаліць выпускі ({0})",
"MessageRemoveFromPlayerQueue": "Выдаліць з чаргі прагравання",
"MessageRemoveUserWarning": "Вы ўпэўнены, што хочаце назаўжды выдаліць карыстальніка \"{0}\"?",
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
"MessageResetChaptersConfirm": "Вы ўпэўнены, што хочаце скінуць раздзелы і адрабіць зробленыя вамі змены?",
"MessageRestoreBackupConfirm": "Вы ўпэўнены, што хочаце аднавіць рэзервовую копію, створаную",
"MessageRestoreBackupWarning": "Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама відарысы вокладкі ў /metadata/items і /metadata/authors. <br /><br /> Рэзервовыя копіі не змяняюць файлы ў папках бібліятэкі. Калі вы ўключылі налады сервера для захоўвання воклак і метаданых у папках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца. <br /><br /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",
+103 -2
View File
@@ -752,7 +752,7 @@
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
"MessageBookshelfNoSeries": "Нямате поредица",
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
@@ -1018,18 +1018,50 @@
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
"ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastChaptersRemoved": "Главите са премахнати",
"ToastChaptersUpdated": "Главите са актуализирани",
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
"ToastEncodeCancelSucces": "Кодирането е отменено",
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
"ToastFailedToCreate": "Неуспешно създаване",
"ToastFailedToDelete": "Неуспешно изтриване",
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
"ToastFailedToMatch": "Неуспешно съвпадение",
"ToastFailedToShare": "Неуспешно споделяне",
"ToastFailedToUpdate": "Неуспешно актуализиране",
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
"ToastInvalidUrl": "Невалиден URL адрес",
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
"ToastItemDeletedSuccess": "Елементът е изтрит",
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
"ToastItemUpdateSuccess": "Елементът е актуализиран",
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
@@ -1037,28 +1069,97 @@
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
"ToastNameEmailRequired": "Изискват се име и имейл",
"ToastNameRequired": "Изисква се име",
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
"ToastNewUserUsernameError": "Въведете потребителско име",
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
"ToastPlaylistCreateSuccess": "Плейлистът е създаден",
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
"ToastRemoveFailed": "Неуспешно премахване",
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
"ToastRenameFailed": "Неуспешно преименуване",
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
"ToastSeriesUpdateSuccess": "Серията е обновена",
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
"ToastSessionDeleteSuccess": "Сесията е изтрита",
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
"ToastSlugRequired": "Изисква се кратък URL (slug)",
"ToastSocketConnected": "Свързан сокет",
"ToastSocketDisconnected": "Сокетът е прекъснат",
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
"ToastTitleRequired": "Изисква се заглавие",
"ToastUnknownError": "Неизвестна грешка",
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
"ToastUserDeleteSuccess": "Потребителят е изтрит"
"ToastUserDeleteSuccess": "Потребителят е изтрит",
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
"ToastUserPasswordMismatch": "Паролите не съвпадат",
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
"TooltipAddChapters": "Добавяне на глава(и)",
"TooltipAddOneSecond": "Добавяне на 1 секунда",
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
"TooltipLockAllChapters": "Заключване на всички глави",
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
"TooltipUnlockAllChapters": "Отключване на всички глави",
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
}
+1 -1
View File
@@ -16,7 +16,7 @@
"ButtonBrowseForFolder": "Mappa keresése",
"ButtonCancel": "Mégse",
"ButtonCancelEncode": "Kódolás megszakítása",
"ButtonChangeRootPassword": "Gyökérjelszó megváltoztatása",
"ButtonChangeRootPassword": "Root jelszó megváltoztatása",
"ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
"ButtonChooseAFolder": "Válassz egy mappát",
"ButtonChooseFiles": "Fájlok kiválasztása",
+930 -36
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{}
+12 -10
View File
@@ -2,7 +2,7 @@
"ButtonAdd": "Toevoegen",
"ButtonAddApiKey": "API Key toevoegen",
"ButtonAddChapters": "Hoofdstukken toevoegen",
"ButtonAddDevice": "Toestel toevoegen",
"ButtonAddDevice": "Apparaat toevoegen",
"ButtonAddLibrary": "Bibliotheek toevoegen",
"ButtonAddPodcasts": "Podcasts toevoegen",
"ButtonAddUser": "Gebruiker toevoegen",
@@ -139,7 +139,7 @@
"HeaderCustomMetadataProviders": "Aangepaste Metadata Providers",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij",
"HeaderEbookFiles": "Ebook bestanden",
"HeaderEbookFiles": "E-book bestanden",
"HeaderEmail": "E-mail",
"HeaderEmailSettings": "E-mail instellingen",
"HeaderEpisodes": "Afleveringen",
@@ -275,7 +275,7 @@
"LabelBonus": "Bonus",
"LabelBooks": "Boeken",
"LabelButtonText": "Knop Tekst",
"LabelByAuthor": "Door {0}",
"LabelByAuthor": "door {0}",
"LabelChangePassword": "Wachtwoord wijzigen",
"LabelChannels": "Kanalen",
"LabelChapterCount": "{0} Hoofdstukken",
@@ -383,7 +383,7 @@
"LabelFolders": "Mappen",
"LabelFontBold": "Vetgedrukt",
"LabelFontBoldness": "Lettertype Dikte",
"LabelFontFamily": "Lettertypefamilie",
"LabelFontFamily": "Letterfamilie",
"LabelFontItalic": "Cursief",
"LabelFontScale": "Lettertype schaal",
"LabelFontStrikethrough": "Doorgestreept",
@@ -436,9 +436,9 @@
"LabelLibraryFilterSublistEmpty": "Nee {0}",
"LabelLibraryItem": "Bibliotheekonderdeel",
"LabelLibraryName": "Bibliotheeknaam",
"LabelLibrarySortByProgress": "Voortuigang geüpdatet",
"LabelLibrarySortByProgressFinished": "Datum voltooid",
"LabelLibrarySortByProgressStarted": "Datum gestart",
"LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet",
"LabelLibrarySortByProgressFinished": "Voortgang: Voltooid",
"LabelLibrarySortByProgressStarted": "Voortgang: Gestart",
"LabelLimit": "Limiet",
"LabelLineSpacing": "Regelruimte",
"LabelListenAgain": "Opnieuw Beluisteren",
@@ -588,8 +588,8 @@
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
"LabelSettingsChromecastSupport": "Chromecast ondersteuning",
"LabelSettingsDateFormat": "Datumnotatie",
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen",
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen",
"LabelSettingsEnableWatcher": "Bibliotheken automatisch monitoren op wijzigingen",
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch monitoren op wijzigingen",
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.",
@@ -888,7 +888,7 @@
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
"MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.",
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.",
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het aangeraden om deze functie uitgeschakeld te laten en de \"Bibliotheek automatisch monitoren op wijzigingen\" instelling ingeschakeld te houden - deze detecteert automatisch wijzigingen in uw bibliotheekmappen. Activeer deze instelling als \"Bibliotheek automatisch monitoren op wijzigingen\" niet werkt voor uw bestandssysteem (zoals NFS).",
"MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}",
"MessageSearchResultsFor": "Zoekresultaten voor",
"MessageSelected": "{0} geselecteerd",
@@ -1026,6 +1026,8 @@
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastConnectionNotAvailable": "Verbinding niet beschikbaar. Gelieve later opnieuw te proberen",
"ToastCoverSearchFailed": "Omslag zoeken mislukt",
"ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
"ToastDeleteFileFailed": "Bestand verwijderen mislukt",
+48
View File
@@ -951,6 +951,11 @@
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
"NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej",
"NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd",
"NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany",
"NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób",
"NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się",
"NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień",
"PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)",
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
@@ -960,6 +965,7 @@
"PlaceholderSearchEpisode": "Szukanie odcinka..",
"StatsAuthorsAdded": "dodano autorów",
"StatsBooksAdded": "dodano książki",
"StatsBooksAdditional": "Niektóre dodatki obejmują…",
"StatsBooksFinished": "ukończone książki",
"StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…",
"StatsBooksListenedTo": "książki wysłuchane",
@@ -976,6 +982,7 @@
"StatsTotalDuration": "O sumarycznej długości…",
"StatsYearInReview": "PRZEGLĄD ROKU",
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
"ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise",
"ToastAsinRequired": "ASIN jest wymagany",
"ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte",
"ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony",
@@ -994,8 +1001,11 @@
"ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej",
"ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej",
"ToastBackupUploadSuccess": "Kopia zapasowa została przesłana",
"ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów",
"ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się",
"ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się",
"ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!",
"ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!",
"ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się",
"ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się",
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
@@ -1033,7 +1043,14 @@
"ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia",
"ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków",
"ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.",
"ToastFailedToCreate": "Nie udało się utworzyć",
"ToastFailedToDelete": "Nie udało się usunąć",
"ToastFailedToLoadData": "Nie udało się załadować danych",
"ToastFailedToMatch": "Nie udało się dopasować",
"ToastFailedToShare": "Nie udało się udostępnić",
"ToastFailedToUpdate": "Nie udało się zaktualizować",
"ToastInvalidImageUrl": "Nieprawidłowy URL obrazu",
"ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania",
"ToastInvalidUrl": "Nieprawidłowy URL",
"ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
@@ -1044,6 +1061,7 @@
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona",
"ToastItemUpdateSuccess": "Element zaktualizowany",
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
@@ -1052,6 +1070,10 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów",
"ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików",
"ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece",
"ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku",
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto",
"ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę",
"ToastNameEmailRequired": "Nazwa i email są wymagane",
"ToastNameRequired": "Imię jest wymagane",
@@ -1065,7 +1087,15 @@
"ToastNewUserUsernameError": "Wprowadź nazwę użytkownika",
"ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków",
"ToastNoRSSFeed": "Podcast nie posiada RSS Feed",
"ToastNoUpdatesNecessary": "Brak konieczności aktualizacji",
"ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia",
"ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia",
"ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0",
"ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0",
"ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień",
"ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego",
"ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe",
"ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane",
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
"ToastPlaylistCreateSuccess": "Playlista utworzona",
"ToastPlaylistRemoveSuccess": "Playlista usunięta",
@@ -1073,8 +1103,17 @@
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
"ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki",
"ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu",
"ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS",
"ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS",
"ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie",
"ToastProviderCreatedFailed": "Nie udało się dodać dostawcy",
"ToastProviderCreatedSuccess": "Dodano nowego dostawcę",
"ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL",
"ToastProviderRemoveSuccess": "Dostawca usunięty",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastRemoveFailed": "Nie udało się usunąć",
"ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji",
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki",
@@ -1096,16 +1135,25 @@
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
"ToastSessionDeleteSuccess": "Sesja usunięta",
"ToastSleepTimerDone": "Słodkich snów... zZzzZz",
"ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki",
"ToastSlugRequired": "Slug jest wymagany",
"ToastSocketConnected": "Nawiązano połączenie z serwerem",
"ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
"ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania",
"ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)",
"ToastTitleRequired": "Tytuł jest wymagany",
"ToastUnknownError": "Nieznany błąd",
"ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID",
"ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID",
"ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze",
"ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.",
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
"ToastUserDeleteSuccess": "Użytkownik usunięty",
"ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione",
"ToastUserPasswordMismatch": "Hasła nie są zgodne",
"ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło",
"ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root",
"TooltipAddChapters": "Dodaj rozdział(y)",
"TooltipAddOneSecond": "Dodaj sekundę",
"TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy",
+5
View File
@@ -31,6 +31,11 @@ if (isDev || options['prod-with-dev-env']) {
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
if (devEnv.AllowedDevOrigins) {
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins)
? devEnv.AllowedDevOrigins.join(',')
: String(devEnv.AllowedDevOrigins)
}
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.33.2",
"version": "2.35.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.33.2",
"version": "2.35.1",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.33.2",
"version": "2.35.1",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
+15 -273
View File
@@ -6,9 +6,9 @@
<br />
<a href="https://audiobookshelf.org/docs">Documentation</a>
·
<a href="https://audiobookshelf.org/guides">User Guides</a>
·
<a href="https://audiobookshelf.org/support">Support</a>
·
<a href="https://audiobooks.dev/">Demo</a>
</p>
</div>
@@ -57,21 +57,17 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
### Build your own tools & clients
Check out the [API documentation](https://api.audiobookshelf.org/)
<br />
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png" />
<br />
# 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.
<br />
@@ -87,275 +83,24 @@ See [install docs](https://www.audiobookshelf.org/docs)
#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)
### NGINX Proxy Manager
See [reverse proxy docs](https://audiobookshelf.org/docs/category/reverse-proxy)
Toggle websockets support.
<img alt="NGINX Web socket" src="https://user-images.githubusercontent.com/67830747/153679106-b2a7f5b9-0702-48c6-9740-b26b401986e9.png" />
### 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 <sub>.<domain>.<tld>;
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://<URL_to_forward_to>;
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
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName <sub>.<domain>.<tld>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
ProxyPreserveHost On
ProxyPass / http://localhost:<audiobookshelf_port>/
RewriteEngine on
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:<audiobookshelf_port>/$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
</VirtualHost>
</IfModule>
```
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
<Location /audiobookshelf>
ProxyPreserveHost on
ProxyPass http://localhost:<audiobookshelf_port>/audiobookshelf upgrade=websocket
ProxyPassReverse http://localhost:<audiobookshelf_port>/audiobookshelf
</Location>
```
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
<VirtualHost *:443>
# ...
# 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:<audiobookshelf_port>/
# ...
</VirtualHost>
```
### 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 | `<sub>.<quickconnectdomain>.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:
<ul>
<li>accessControlAllowMethods</li>
<li>accessControlAllowOriginList</li>
<li>accessControlMaxAge</li>
</ul>
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506) <br />
### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
```
subdomain.domain.com {
encode gzip zstd
reverse_proxy <LOCAL_IP>:<PORT>
}
```
### 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
<br />
# 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). <a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
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).
<a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
<br />
# Run from source
This application is built using [NodeJs](https://nodejs.org/).
### Dev Container Setup
@@ -447,6 +192,3 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
- `Debug client (nuxt)`—Run the client with live reload.
- `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
# How to Support
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
+1 -1
View File
@@ -81,7 +81,7 @@ class Auth {
* @param {import('./models/User')} user
* @param {Request} req
* @param {Response} res
* @returns {Promise<string>} 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)
+5 -3
View File
@@ -14,6 +14,7 @@ const { version } = require('../package.json')
const is = require('./libs/requestIp/isJs')
const fileUtils = require('./utils/fileUtils')
const { toNumber } = require('./utils/index')
const { getRequestOrigin } = require('./utils/requestUtils')
const Logger = require('./Logger')
const Auth = require('./Auth')
@@ -288,10 +289,9 @@ class Server {
// if RouterBasePath is set, modify all requests to include the base path
app.use((req, res, next) => {
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
const host = req.get('host')
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const { origin } = getRequestOrigin(req)
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
req.originalHostPrefix = `${protocol}://${host}${prefix}`
req.originalHostPrefix = `${origin}${prefix}`
if (!urlStartsWithRouterBasePath) {
req.url = `${global.RouterBasePath}${req.url}`
}
@@ -302,7 +302,9 @@ class Server {
this.server = http.createServer(app)
// Skip file upload parsing for internal-api routes (Next.js proxies read multipart bodies).
router.use(
/^(?!\/internal-api).*/,
fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
+22 -2
View File
@@ -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
+28 -37
View File
@@ -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
+134 -28
View File
@@ -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
@@ -75,7 +94,9 @@ class TokenManager {
*/
static validateAccessToken(token) {
try {
return jwt.verify(token, TokenManager.TokenSecret)
const decoded = jwt.verify(token, TokenManager.TokenSecret)
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
return decoded
} catch (err) {
return null
}
@@ -115,6 +136,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
jti: uuid.v4(),
type: 'access'
}
const options = {
@@ -138,6 +160,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
jti: uuid.v4(),
type: 'refresh'
}
const options = {
@@ -183,20 +206,60 @@ class TokenManager {
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {boolean} gracePeriod - whether to use the grace period
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/
async rotateTokensForSession(session, user, req, res) {
// Generate new tokens
async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
const previousRefreshToken = session.refreshToken
const newAccessToken = this.generateTempAccessToken(user)
const newRefreshToken = this.generateRefreshToken(user)
// Calculate new expiration time
let newRefreshToken = this.generateRefreshToken(user)
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
// Update the session with the new refresh token and expiration
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
await session.save()
let lastRefreshToken = null
let lastRefreshTokenExpiresAt = null
if (gracePeriod) {
// Set grace period of old refresh token in case of race condition in token rotation.
// During this window a retry with the old refresh token returns the already-rotated
// current token instead of failing, so a client that never received the rotation
// response (e.g. dropped/suspended mobile request) can still recover the session.
// Configurable via REFRESH_TOKEN_GRACE_PERIOD; may need to be longer if fetching the
// user data takes longer due to large progress objects.
lastRefreshToken = previousRefreshToken
lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
}
// Only update if this session row still has the refresh token we read
const [numUpdated] = await Database.sessionModel.update(
{
refreshToken: newRefreshToken,
expiresAt: newExpiresAt,
lastRefreshToken,
lastRefreshTokenExpiresAt
},
{
where: {
id: session.id,
refreshToken: previousRefreshToken
}
}
)
if (numUpdated === 0) {
Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
newRefreshToken = updatedSession.refreshToken
session.refreshToken = updatedSession.refreshToken
session.expiresAt = updatedSession.expiresAt
session.lastRefreshToken = updatedSession.lastRefreshToken
session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
} else {
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
session.lastRefreshToken = lastRefreshToken
session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
}
// Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken)
@@ -243,7 +306,11 @@ class TokenManager {
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) {
@@ -294,23 +361,40 @@ class TokenManager {
}
}
const session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken }
let session = await Database.sessionModel.findOne({
where: {
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
}
})
if (!session) {
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
return {
error: 'Invalid refresh token'
}
}
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
let isGracePeriod = false
if (session.refreshToken !== refreshToken) {
// Token matched lastRefreshToken
if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
isGracePeriod = true
Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
} else {
Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
return {
error: 'Invalid refresh token'
}
}
} else {
// Token matched current refreshToken
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
}
}
}
@@ -322,6 +406,20 @@ class TokenManager {
}
}
if (isGracePeriod) {
// Return the already rotated refresh token store in the database,
// and generate a new access token without changing the refresh token
// again
const accessToken = this.generateTempAccessToken(user)
this.setRefreshTokenCookie(req, res, session.refreshToken)
return {
accessToken,
refreshToken: session.refreshToken,
user
}
}
const newTokens = await this.rotateTokensForSession(session, user, req, res)
return {
accessToken: newTokens.accessToken,
@@ -366,16 +464,21 @@ class TokenManager {
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
* @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({
@@ -387,9 +490,12 @@ class TokenManager {
}
})
return newTokens.accessToken
return {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken
}
} else {
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
Logger.error(`[TokenManager] No session found to rotate tokens`)
}
}
@@ -413,7 +519,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}`)
+1 -1
View File
@@ -149,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()
}
+10 -1
View File
@@ -41,6 +41,10 @@ class CollectionController {
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')
@@ -109,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
})
}
@@ -431,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
}
+9 -6
View File
@@ -1432,14 +1432,17 @@ class LibraryController {
const itemIds = req.query.ids.split(',')
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'libraryId', 'path', 'isFile'],
where: {
id: itemIds,
libraryId: req.library.id
}
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}"`)
}
@@ -1,6 +1,7 @@
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')
@@ -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() {}
@@ -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
@@ -547,7 +571,13 @@ class LibraryItemController {
return res.sendStatus(404)
}
// Ensure user has permission to delete these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
return
}
const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
@@ -581,6 +611,7 @@ class LibraryItemController {
}
await Database.resetLibraryIssuesFilterData(libraryId)
res.sendStatus(200)
}
@@ -593,6 +624,11 @@ class LibraryItemController {
* @param {Response} res
*/
async batchUpdate(req, res) {
if (!req.user.canUpdate) {
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`)
return res.sendStatus(403)
}
const updatePayloads = req.body
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
@@ -615,6 +651,11 @@ class LibraryItemController {
return res.sendStatus(404)
}
// Ensure user has permission to update these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
let itemsUpdated = 0
const seriesIdsRemoved = []
@@ -624,6 +665,11 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`)
continue
}
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
@@ -695,6 +741,10 @@ class LibraryItemController {
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
// Ensure user has permission to access these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
res.json({
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
})
+63
View File
@@ -26,6 +26,49 @@ class MeController {
res.json(req.user.toOldJSONForBrowser())
}
/**
* 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
*
@@ -308,6 +351,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
@@ -330,6 +375,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)
}
+5 -7
View File
@@ -8,7 +8,7 @@ const Database = require('../Database')
const Watcher = require('../Watcher')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation')
const cron = require('../libs/nodeCron')
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
@@ -605,13 +605,11 @@ class MiscController {
return res.sendStatus(400)
}
try {
patternValidation(expression)
res.sendStatus(200)
} catch (error) {
Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
res.status(400).send(error.message)
if (!cron.validate(expression)) {
Logger.warn(`[MiscController] Invalid cron expression ${expression}`)
return res.status(400).send('Invalid cron expression')
}
res.sendStatus(200)
}
/**
+14 -1
View File
@@ -37,6 +37,10 @@ class PlaylistController {
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()
@@ -133,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
})
}
@@ -508,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) {
@@ -573,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
}
+17
View File
@@ -5,6 +5,7 @@ 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, isSameOrSubPath } = require('../utils/fileUtils')
@@ -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}"`)
@@ -431,6 +437,17 @@ class PodcastController {
}
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') {
+4
View File
@@ -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 {
+11 -12
View File
@@ -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
+7 -4
View File
@@ -42,11 +42,14 @@ class ApiCacheManager {
}
clearUserProgressSlices(modelName, hook) {
const removedPersonalized = this.modelsInvalidatingPersonalized.has(modelName) ? this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/) : 0
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}, me=${removedMe})`
)
Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`)
}
clear(model, hook) {
+6 -1
View File
@@ -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)
}
}
+6
View File
@@ -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)
}
@@ -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<void>} - 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<void>} - 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 }
+13 -6
View File
@@ -647,6 +647,11 @@ class Book extends Model {
}
}
/**
* Minified book JSON for list/shelf endpoints.
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
*/
toOldJSONMinified() {
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
@@ -669,6 +674,12 @@ class Book extends Model {
}
}
/**
* Expanded book JSON for item detail and socket events.
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
*
* @param {string} libraryItemId
*/
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
@@ -681,16 +692,12 @@ class Book extends Model {
}
return {
id: this.id,
libraryItemId: libraryItemId,
...this.toOldJSONMinified(),
libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
audioFiles: structuredClone(this.audioFiles),
chapters: structuredClone(this.chapters),
ebookFile: structuredClone(this.ebookFile),
duration: this.duration,
size: this.size,
tracks: this.getTracklist(libraryItemId)
}
}
+27 -1
View File
@@ -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<Record<string, number>>}
*/
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<string, number>} */
const counts = {}
for (const row of rows) {
counts[row.authorId] = Number(row.count)
}
return counts
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
+11 -18
View File
@@ -1004,6 +1004,11 @@ class LibraryItem extends Model {
}
}
/**
* Minified library item JSON for list/shelf endpoints.
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
*/
toOldJSONMinified() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
@@ -1032,30 +1037,18 @@ class LibraryItem extends Model {
}
}
/**
* Expanded library item JSON for item detail and socket events.
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
*/
toOldJSONExpanded() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
...this.toOldJSONMinified(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONExpanded(this.id),
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
libraryFiles: this.getLibraryFilesJson(),
size: this.size
libraryFiles: this.getLibraryFilesJson()
}
}
}
+20 -13
View File
@@ -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))
}
}
}
+12
View File
@@ -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
}
},
{
+10 -11
View File
@@ -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')
}
}
}
}
)
}
+6 -1
View File
@@ -96,7 +96,12 @@ class DeviceInfo {
this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
this.model = stripAllTags(clientDeviceInfo?.model) || null
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || 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) {
+13
View File
@@ -260,6 +260,18 @@ class ServerSettings {
}
}
/**
* Host timezone used by cron schedulers (not persisted in settings)
* @returns {string} IANA timezone name, e.g. "America/New_York"
*/
static getHostTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
toJSONForBrowser() {
const json = this.toJSON()
delete json.tokenSecret
@@ -268,6 +280,7 @@ class ServerSettings {
delete json.authOpenIDMobileRedirectURIs
delete json.authOpenIDGroupClaim
delete json.authOpenIDAdvancedPermsClaim
json.timeZone = ServerSettings.getHostTimeZone()
return json
}
+3
View File
@@ -171,6 +171,9 @@ class ApiRouter {
// Current User Routes (Me)
//
this.router.get('/me', MeController.getCurrentUser.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))
+15 -14
View File
@@ -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()
module.exports = new AbsMetadataFileScanner()
+46
View File
@@ -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
+8 -4
View File
@@ -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()
+2
View File
@@ -24,6 +24,8 @@ class LibraryScan {
/** @type {string[]} */
this.authorsRemovedFromBooks = []
/** @type {Set<string>} */
this.authorsNumBooksChangedIds = new Set()
/** @type {string[]} */
this.seriesRemovedFromBooks = []
+4 -2
View File
@@ -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
}
+8
View File
@@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile')
const fsExtra = require('../libs/fsExtra')
const PodcastEpisode = require('../models/PodcastEpisode')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const htmlSanitizer = require('../utils/htmlSanitizer')
/**
* Metadata for podcasts pulled from files
@@ -398,6 +399,10 @@ class PodcastScanner {
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
}
return podcastMetadata
}
@@ -420,6 +425,9 @@ class PodcastScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
/**
* Keys must match abmetadataGenerator.js
*/
const jsonObject = {
tags: libraryItem.media.tags || [],
title: libraryItem.media.title,
+2
View File
@@ -14,6 +14,8 @@ class ScanLogger {
/** @type {string[]} */
this.authorsRemovedFromBooks = []
/** @type {Set<string>} */
this.authorsNumBooksChangedIds = new Set()
/** @type {string[]} */
this.seriesRemovedFromBooks = []
+6 -1
View File
@@ -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}`)
+124 -19
View File
@@ -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
+1 -1
View File
@@ -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
+43
View File
@@ -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
}
+77
View File
@@ -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)
})
})
+142
View File
@@ -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)
})
})
})
@@ -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')
})
})
@@ -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
})
})
})
@@ -1,636 +0,0 @@
const { expect } = require('chai')
const { Sequelize } = require('sequelize')
const sinon = require('sinon')
const Database = require('../../../server/Database')
const ApiRouter = require('../../../server/routers/ApiRouter')
const MeController = require('../../../server/controllers/MeController')
const Auth = require('../../../server/Auth')
const Logger = require('../../../server/Logger')
const SocketAuthority = require('../../../server/SocketAuthority')
describe('MeController - IDOR Security Tests', () => {
/** @type {ApiRouter} */
let apiRouter
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()
// Create mock server object with required dependencies
const mockServer = {
auth: new Auth(),
playbackSessionManager: { sessions: [] },
abMergeManager: {},
backupManager: {},
podcastManager: {},
audioMetadataManager: {},
cronManager: {},
emailManager: {},
apiCacheManager: { middleware: (req, res, next) => next() }
}
apiRouter = new ApiRouter(mockServer)
sinon.stub(Logger, 'info')
sinon.stub(Logger, 'error')
sinon.stub(SocketAuthority, 'clientEmitter')
})
afterEach(async () => {
sinon.restore()
// Clear all tables
await Database.sequelize.sync({ force: true })
})
describe('removeMediaProgress - IDOR Protection', () => {
let user1, user2
let mediaProgress1, mediaProgress2
beforeEach(async () => {
// Create two users
user1 = await Database.userModel.create({
username: 'user1',
pash: 'hashed_password_1',
type: 'user',
isActive: true
})
user2 = await Database.userModel.create({
username: 'user2',
pash: 'hashed_password_2',
type: 'user',
isActive: true
})
// Create library and book
const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const libraryItem = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book.id,
mediaType: 'book',
libraryId: library.id,
libraryFolderId: libraryFolder.id
})
// Create media progress for each user
mediaProgress1 = await Database.mediaProgressModel.create({
userId: user1.id,
mediaItemId: book.id,
mediaItemType: 'book',
duration: 1000,
currentTime: 500,
isFinished: false
})
mediaProgress2 = await Database.mediaProgressModel.create({
userId: user2.id,
mediaItemId: book.id,
mediaItemType: 'book',
duration: 1000,
currentTime: 300,
isFinished: false
})
// Load media progresses into users
user1.mediaProgresses = await user1.getMediaProgresses()
user2.mediaProgresses = await user2.getMediaProgresses()
})
it('should allow user to delete their own media progress', async () => {
const fakeReq = {
user: user1,
params: { id: mediaProgress1.id }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy()
}
await MeController.removeMediaProgress(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
// Verify media progress was deleted
const deletedProgress = await Database.mediaProgressModel.findByPk(mediaProgress1.id)
expect(deletedProgress).to.be.null
})
it('should prevent user from deleting another users media progress (IDOR)', async () => {
const fakeReq = {
user: user1,
params: { id: mediaProgress2.id } // Trying to delete user2's progress
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy()
}
await MeController.removeMediaProgress(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
// Verify media progress was NOT deleted
const existingProgress = await Database.mediaProgressModel.findByPk(mediaProgress2.id)
expect(existingProgress).to.not.be.null
expect(existingProgress.userId).to.equal(user2.id)
})
it('should return 404 for non-existent media progress', async () => {
const fakeReq = {
user: user1,
params: { id: 'non-existent-id' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy()
}
await MeController.removeMediaProgress(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
})
})
describe('Bookmark Operations - Authorization Checks', () => {
let user1, user2
let library1, library2
let libraryItem1, libraryItem2
beforeEach(async () => {
// Create two users with different library access
user1 = await Database.userModel.create({
username: 'user1',
pash: 'hashed_password_1',
type: 'user',
isActive: true,
librariesAccessible: null // Access to all libraries
})
user2 = await Database.userModel.create({
username: 'user2',
pash: 'hashed_password_2',
type: 'user',
isActive: true,
librariesAccessible: [] // Will be set to specific library
})
// Create two libraries
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
// User2 only has access to library1
user2.librariesAccessible = [library1.id]
await user2.save()
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
libraryItem1 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book1.id,
mediaType: 'book',
libraryId: library1.id,
libraryFolderId: libraryFolder1.id
})
libraryItem2 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book2.id,
mediaType: 'book',
libraryId: library2.id,
libraryFolderId: libraryFolder2.id
})
// Initialize bookmarks
user1.bookmarks = []
user2.bookmarks = []
})
describe('createBookmark', () => {
it('should allow user to create bookmark for accessible library item', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() }
const fakeReq = {
user: {
...user2.toJSON(),
id: user2.id,
username: user2.username,
checkCanAccessLibraryItem: () => true,
createBookmark: sinon.stub().resolves(bookmark),
toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username })
},
params: { id: libraryItem1.id },
body: { time: 100, title: 'Test Bookmark' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.createBookmark(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
expect(fakeRes.json.calledWith(bookmark)).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
it('should prevent user from creating bookmark for inaccessible library item (IDOR)', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
const fakeReq = {
user: user2, // user2 doesn't have access to library2
params: { id: libraryItem2.id },
body: { time: 100, title: 'Test Bookmark' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
// Mock getExpandedById
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.createBookmark(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
expect(fakeRes.json.called).to.be.false
Database.libraryItemModel.getExpandedById.restore()
})
it('should return 404 for non-existent library item', async () => {
const fakeReq = {
user: user1,
params: { id: 'non-existent-id' },
body: { time: 100, title: 'Test Bookmark' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
// Mock getExpandedById to return null
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
await MeController.createBookmark(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
it('should validate bookmark time parameter', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
const fakeReq = {
user: {
...user1.toJSON(),
id: user1.id,
username: user1.username,
checkCanAccessLibraryItem: () => true
},
params: { id: libraryItem1.id },
body: { time: null, title: 'Test Bookmark' } // null time is invalid
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.createBookmark(fakeReq, fakeRes)
expect(fakeRes.status.calledWith(400)).to.be.true
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
})
describe('updateBookmark', () => {
beforeEach(async () => {
// Add existing bookmark to user1
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Original Title' }]
await user1.save()
})
it('should allow user to update bookmark for accessible library item', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' }
const fakeReq = {
user: {
...user1.toJSON(),
id: user1.id,
username: user1.username,
checkCanAccessLibraryItem: () => true,
updateBookmark: sinon.stub().resolves(bookmark),
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
},
params: { id: libraryItem1.id },
body: { time: 100, title: 'Updated Title' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.updateBookmark(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
expect(fakeRes.json.calledWith(bookmark)).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
it('should prevent user from updating bookmark for inaccessible library item (IDOR)', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
const fakeReq = {
user: user2, // user2 doesn't have access to library2
params: { id: libraryItem2.id },
body: { time: 100, title: 'Updated Title' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.updateBookmark(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
})
describe('removeBookmark', () => {
beforeEach(async () => {
// Add existing bookmark to user1
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }]
await user1.save()
})
it('should allow user to remove bookmark for accessible library item', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
const fakeReq = {
user: {
...user1.toJSON(),
id: user1.id,
username: user1.username,
checkCanAccessLibraryItem: () => true,
findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }),
removeBookmark: sinon.stub().resolves(true),
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
},
params: { id: libraryItem1.id, time: '100' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.removeBookmark(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
it('should prevent user from removing bookmark for inaccessible library item (IDOR)', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
const fakeReq = {
user: user2, // user2 doesn't have access to library2
params: { id: libraryItem2.id, time: '100' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.removeBookmark(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
it('should validate time parameter is a number', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
const fakeReq = {
user: {
...user1.toJSON(),
id: user1.id,
username: user1.username,
checkCanAccessLibraryItem: () => true
},
params: { id: libraryItem1.id, time: 'not-a-number' }
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
await MeController.removeBookmark(fakeReq, fakeRes)
expect(fakeRes.status.calledWith(400)).to.be.true
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
})
})
describe('getItemListeningSessions - Authorization Check', () => {
let user1, user2
let library1, library2
let libraryItem1, libraryItem2
beforeEach(async () => {
// Create two users with different library access
user1 = await Database.userModel.create({
username: 'user1',
pash: 'hashed_password_1',
type: 'user',
isActive: true,
librariesAccessible: null // Access to all libraries
})
user2 = await Database.userModel.create({
username: 'user2',
pash: 'hashed_password_2',
type: 'user',
isActive: true,
librariesAccessible: [] // Will be set to specific library
})
// Create two libraries
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
// User2 only has access to library1
user2.librariesAccessible = [library1.id]
await user2.save()
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
libraryItem1 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book1.id,
mediaType: 'book',
libraryId: library1.id,
libraryFolderId: libraryFolder1.id
})
libraryItem2 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book2.id,
mediaType: 'book',
libraryId: library2.id,
libraryFolderId: libraryFolder2.id
})
})
it('should allow user to view listening sessions for accessible library item', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
// Create mock context with getUserItemListeningSessionsHelper
const mockContext = {
getUserItemListeningSessionsHelper: sinon.stub().resolves([{ id: 'session1', timeListening: 300, startedAt: Date.now() }])
}
const fakeReq = {
user: {
...user1.toJSON(),
id: user1.id,
username: user1.username,
checkCanAccessLibraryItem: () => true
},
params: { libraryItemId: libraryItem1.id },
query: {}
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
await MeController.getItemListeningSessions.bind(mockContext)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
expect(fakeRes.sendStatus.called).to.be.false
// Verify the payload structure
const payload = fakeRes.json.firstCall.args[0]
expect(payload).to.have.property('total')
expect(payload).to.have.property('sessions')
Database.libraryItemModel.getExpandedById.restore()
Database.podcastEpisodeModel.findByPk.restore()
})
it('should prevent user from viewing listening sessions for inaccessible library item (IDOR)', async () => {
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
const fakeReq = {
user: user2, // user2 doesn't have access to library2
params: { libraryItemId: libraryItem2.id },
query: {}
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
expect(fakeRes.json.called).to.be.false
Database.libraryItemModel.getExpandedById.restore()
Database.podcastEpisodeModel.findByPk.restore()
})
it('should return 404 for non-existent library item', async () => {
const fakeReq = {
user: user1,
params: { libraryItemId: 'non-existent-id' },
query: {}
}
const fakeRes = {
sendStatus: sinon.spy(),
status: sinon.stub().returnsThis(),
send: sinon.spy(),
json: sinon.spy()
}
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
Database.libraryItemModel.getExpandedById.restore()
})
})
})
@@ -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
})
})
@@ -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
})
})
})
@@ -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)
})
})
})
+40
View File
@@ -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'
})
})
})