Compare commits

..
25 Commits
Author SHA1 Message Date
advplyr edb1f7efc9 Version bump v2.37.2 2026-10-23 14:36:19 -05:00
advplyr 92517f473b Update devcontainer.json node variant to 24 2026-10-23 14:19:39 -05:00
advplyr 9715c7a8f7 Merge pull request #5668 from nichwall/fix/issue-5637-copy-fallback
Add EPERM copy fallback
2026-10-22 17:36:02 -05:00
advplyr 95cb5bad27 Remove fs-extra sync updates 2026-10-22 17:34:09 -05:00
Nicholas Wallace 5cb461f9b5 Add EPERM move fallback 2026-10-20 21:55:23 -07:00
Nicholas Wallace 16aa80ed5c Add EPERM copy fallback 2026-10-20 21:39:26 -07:00
advplyr 743d915220 Normalize RouterBasePath env variable 2026-10-05 17:45:14 -05:00
advplyr 34715558df Use transaction and check root on server init 2026-10-04 20:03:56 -05:00
advplyr 2a0507ffc6 Update linuxpackager to only set 0775 on required files #5629 2026-10-02 17:19:38 -05:00
advplyr cff295fdc9 Merge pull request #5418 from SharonZhu0122/fix-5306-feed-cover-cache
Fix RSS feed cover images not updating after cover change (#5306)
2026-10-01 18:13:31 -05:00
advplyr 6b60beff6e Extract out rss feed image URL construction into a separate function 2026-10-01 18:13:09 -05:00
advplyr db433ce076 Merge pull request #5430 from boqiaok/fix/issue-5176-audible-products
Fix Audible search when products are null
2026-10-01 17:56:26 -05:00
advplyr 5edd20dfa3 Merge pull request #5437 from rtainaan/codex/handle-repeated-upload-files
Fix upload crash with repeated file fields
2026-10-01 17:52:59 -05:00
advplyr 3563d49424 Version bump v2.37.1 2026-09-29 17:30:00 -05:00
advplyr 06935b78b5 Merge pull request #5615 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-09-29 17:24:44 -05:00
Gabriel 4aee64da07 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/
2026-09-30 00:18:46 +02:00
Martynas 02cf2da4fa Translated using Weblate (Lithuanian)
Currently translated at 60.1% (702 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2026-09-30 00:18:46 +02:00
Indrek Ankur 366320bf94 Translated using Weblate (Estonian)
Currently translated at 64.2% (750 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/et/
2026-09-30 00:18:45 +02:00
advplyr a2cba8dd05 OIDC extend cookie max age to 10 minutes and redirect to login page when no session #5614 2026-09-29 17:18:38 -05:00
advplyr 86bc18487b Merge pull request #5606 from SharonZhu0122/fix-5604-oidc-session-cleanup
Expire abandoned OIDC mobile login sessions
2026-09-29 16:51:30 -05:00
advplyr 9e18cb41e9 Fix EACCES error for Debian install #5613
- Update systemd service file to define HOME path and CacheDirectory to support Node24 change
2026-09-29 16:33:13 -05:00
zsh 273693e8ad Expire abandoned OIDC mobile login sessions 2026-09-25 17:38:37 +12:00
Raymond (Kai) Tainaan 3f9afe57bc Fix upload crash with repeated file fields
The multipart middleware returns an array when a field is repeated, but handleUpload treats every value as one file. Flatten the values before moving them so repeated and unique field names both work.

Signed-off-by: Raymond (Kai) Tainaan <282025710+rtainaan@users.noreply.github.com>
2026-08-06 23:36:07 -07:00
Asher Jin ac63b0c7fc Fix Audible search when products are null 2026-08-04 14:10:32 +12:00
zsh 94400acdcd fix: add cache-busting timestamp to RSS feed cover image URLs
Feed cover image URLs were built only from the item's slug and file
extension, so the URL never changed when the cover image itself
changed. Podcast clients that cache by URL would keep showing the old
cover after an update.

Add a '?ts=' query parameter using the already-computed
entityUpdatedAt value, matching the same cache-busting convention
already used elsewhere in the app (see the updatedAt comment in
LibraryItemController.updateCover).

Applies to all three feed types: single item, collection, and series.

Fixes #5306
2026-07-31 10:28:21 +12:00
22 changed files with 263 additions and 76 deletions

No files matched your search

+28 -37
View File
@@ -1,40 +1,31 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Audiobookshelf",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "20"
}
},
"mounts": [
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000,
3333
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"octref.vetur"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
"name": "Audiobookshelf",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "24"
}
},
"mounts": ["source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000, 3333],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": ["dbaeumer.vscode-eslint", "octref.vetur"]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
@@ -11,6 +11,8 @@ ExecReload=/bin/kill -HUP $MAINPID
Restart=always
User=audiobookshelf
Group=audiobookshelf
Environment=HOME=/var/cache/audiobookshelf
CacheDirectory=audiobookshelf
[Install]
WantedBy=multi-user.target
+6 -1
View File
@@ -35,7 +35,6 @@ echo "--------------------"
mkdir -p dist
rm -rf dist/debian
cp -R build/debian dist/debian
chmod -R 775 dist/debian
controlfile="Package: audiobookshelf
Version: $VERSION
@@ -48,6 +47,12 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# Package contents: directories 0755, files 0644 (including the systemd unit).
# Maintainer scripts must stay executable so dpkg can run them.
find dist/debian -type d -exec chmod 755 {} +
find dist/debian -type f -exec chmod 644 {} +
chmod 755 dist/debian/DEBIAN/preinst dist/debian/DEBIAN/postinst dist/debian/DEBIAN/prerm
# Package debian
pkg --config package.json -t node24-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf dist-server/index.js
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.37.0",
"version": "2.37.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.37.0",
"version": "2.37.2",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.37.0",
"version": "2.37.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
+12 -9
View File
@@ -1,5 +1,6 @@
{
"ButtonAdd": "Lisa",
"ButtonAddApiKey": "Lisa API võti",
"ButtonAddChapters": "Lisa peatükid",
"ButtonAddDevice": "Lisa seade",
"ButtonAddLibrary": "Lisa raamatukogu",
@@ -53,7 +54,7 @@
"ButtonOpenFeed": "Ava voog",
"ButtonOpenManager": "Ava haldur",
"ButtonPause": "Paus",
"ButtonPlay": "Play",
"ButtonPlay": "Mängi",
"ButtonPlayAll": "Mängi kõik",
"ButtonPlaying": "Mängib",
"ButtonPlaylists": "Playlist",
@@ -159,7 +160,7 @@
"HeaderPlaylistItems": "Playlisti esemed",
"HeaderPodcastsToAdd": "Lisatavad podcastid",
"HeaderPreviewCover": "Eelvaate kaas",
"HeaderRSSFeedGeneral": "RSS-i üksikasjad",
"HeaderRSSFeedGeneral": "RSS detailid",
"HeaderRSSFeedIsOpen": "RSS-voog on avatud",
"HeaderRSSFeeds": "RSS-vooged",
"HeaderRemoveEpisode": "Eemalda episood",
@@ -177,7 +178,7 @@
"HeaderSleepTimer": "Unetaimer",
"HeaderStatsLargestItems": "Suurimad esemed",
"HeaderStatsLongestItems": "Kõige pikemad esemed (tunnid)",
"HeaderStatsMinutesListeningChart": "Kuulamise minutid (viimased 7 päeva)",
"HeaderStatsMinutesListeningChart": "Kuulatud minutid (viimased 7 päeva)",
"HeaderStatsRecentSessions": "Hiljutised sessioonid",
"HeaderStatsTop10Authors": "Top 10 autorit",
"HeaderStatsTop5Genres": "Top 5 žanrit",
@@ -245,7 +246,7 @@
"LabelConfirmPassword": "Kinnita parool",
"LabelContinueListening": "Jätka kuulamist",
"LabelContinueReading": "Jätka lugemist",
"LabelContinueSeries": "Jätka seeriat",
"LabelContinueSeries": "Jäta sarja",
"LabelCover": "Ümbris",
"LabelCoverImageURL": "Ümbrise pildi URL",
"LabelCreatedAt": "Loodud",
@@ -325,9 +326,10 @@
"LabelIntervalEveryDay": "Iga päev",
"LabelIntervalEveryHour": "Iga tunni tagant",
"LabelInvert": "Pööra ümber",
"LabelIpAddress": "IP-aadress",
"LabelItem": "Kirje",
"LabelLanguage": "Keel",
"LabelLanguageDefaultServer": "Vaikeserveri keel",
"LabelLanguageDefaultServer": "Serveri vaikekeel",
"LabelLastBookAdded": "Viimati lisatud raamat",
"LabelLastBookUpdated": "Viimati uuendatud raamat",
"LabelLastSeen": "Viimati nähtud",
@@ -368,8 +370,8 @@
"LabelNarrators": "Jutustajad",
"LabelNew": "Uus",
"LabelNewPassword": "Uus parool",
"LabelNewestAuthors": "Uuemad autorid",
"LabelNewestEpisodes": "Uuemad episoodid",
"LabelNewestAuthors": "Uusimad autorid",
"LabelNewestEpisodes": "Uusimad episoodid",
"LabelNextBackupDate": "Järgmine varukoopia kuupäev",
"LabelNextScheduledRun": "Järgmine ajakava järgmine",
"LabelNoEpisodesSelected": "Episoodid pole valitud",
@@ -458,8 +460,8 @@
"LabelSettingsFindCoversHelp": "Kui teie heliraamatul pole sisseehitatud ümbrist ega ümbrise pilti kaustas, proovib skanner leida ümbrist.<br>Märkus: see pikendab skaneerimisaega",
"LabelSettingsHideSingleBookSeries": "Peida üksikute raamatute seeriad",
"LabelSettingsHideSingleBookSeriesHelp": "Ühe raamatuga seeriaid peidetakse seeria lehelt ja avalehe riiulitelt.",
"LabelSettingsHomePageBookshelfView": "Avaleht kasutage raamatukoguvaadet",
"LabelSettingsLibraryBookshelfView": "Raamatukogu kasutamiseks kasutage raamatukoguvaadet",
"LabelSettingsHomePageBookshelfView": "Avalehel kasuta raamaturiiulivaadet",
"LabelSettingsLibraryBookshelfView": "Raamatukogus kasuta raamaturiiulivaadet",
"LabelSettingsParseSubtitles": "Lugege subtiitreid",
"LabelSettingsParseSubtitlesHelp": "Eraldage subtiitrid heliraamatu kaustade nimedest.<br>Subtiitrid peavad olema eraldatud kasutades \" - \".<br>Näiteks: \"Raamatu pealkiri - Siin on alapealkiri\" alapealkiri on \"Siin on alapealkiri\"",
"LabelSettingsPreferMatchedMetadata": "Eelista sobitatud metaandmeid",
@@ -565,6 +567,7 @@
"MessageBookshelfNoCollectionsHelp": "Kollektsioonid on avalikud. Kõik kasutajad kellel on olemas ligipääs raamatukogule saavad neid näha.",
"MessageBookshelfNoRSSFeeds": "Ühtegi RSS-i voogu pole avatud",
"MessageBookshelfNoResultsForFilter": "Filtrile \"{0}: {1}\" pole tulemusi",
"MessageBookshelfNoResultsForQuery": "Päringul ei ole tulemusi",
"MessageBookshelfNoSeries": "Teil pole ühtegi seeriat",
"MessageChapterEndIsAfter": "Peatüki lõpp on pärast teie heliraamatu lõppu",
"MessageChapterErrorFirstNotZero": "Esimene peatükk peab algama 0-st",
+2 -2
View File
@@ -233,7 +233,7 @@
"LabelComplete": "Baigta",
"LabelConfirmPassword": "Patvirtinkite slaptažodį",
"LabelContinueListening": "Klausytis toliau",
"LabelContinueReading": "Tęsti skaitymą",
"LabelContinueReading": "Skaityti toliau",
"LabelContinueSeries": "Tęsti seriją",
"LabelCover": "Viršelis",
"LabelCoverImageURL": "Viršelio paveikslėlio URL",
@@ -395,7 +395,7 @@
"LabelRead": "Skaityta",
"LabelReadAgain": "Skaityti dar kartą",
"LabelReadEbookWithoutProgress": "Skaityti e-knygą be pažangos saugojimo",
"LabelRecentSeries": "Naujausiais įkeltos serijos",
"LabelRecentSeries": "Naujausios serijos",
"LabelRecentlyAdded": "Naujausiai įkelta",
"LabelRecommended": "Rekomenduojama",
"LabelRegion": "Regionas",
+1 -1
View File
@@ -1113,7 +1113,7 @@
"ToastProgressIsNotBeingSynced": "O progresso não está sendo sincronizado, reinicie a reprodução",
"ToastProviderCreatedFailed": "Falha ao adicionar o provedor",
"ToastProviderCreatedSuccess": "Novo provedor adicionado",
"ToastProviderNameAndUrlRequired": "Nome e URL obrigatórios",
"ToastProviderNameAndUrlRequired": "Nome e Url obrigatórios",
"ToastProviderRemoveSuccess": "Fornecedor removido",
"ToastRSSFeedCloseFailed": "Falha ao fechar feed RSS",
"ToastRSSFeedCloseSuccess": "Feed RSS fechado",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.37.0",
"version": "2.37.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.37.0",
"version": "2.37.2",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.37.0",
"version": "2.37.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "dist-server/index.js",
+13 -5
View File
@@ -216,14 +216,15 @@ class Auth {
* @returns {Object|null} - Returns error object if validation fails, null if successful
*/
paramsToCookies(req, res, authMethod = 'local') {
const TWO_MINUTES = 120000 // 2 minutes in milliseconds
// Cookie max age in milliseconds
const TEN_MINUTES = 10 * 60 * 1000
const callback = req.query.redirect_uri || req.query.callback
// Additional handling for non-API based authMethod
if (!this.isAuthMethodAPIBased(authMethod)) {
// Store 'auth_state' if present in the request
if (req.query.state) {
res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true })
res.cookie('auth_state', req.query.state, { maxAge: TEN_MINUTES, httpOnly: true })
}
// Validate and store the callback URL
@@ -239,7 +240,7 @@ class Auth {
return { error: 'Invalid callback URL - must be same-origin' }
}
res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })
res.cookie('auth_cb', callback, { maxAge: TEN_MINUTES, httpOnly: true })
}
// Store the authentication method for long
@@ -273,7 +274,8 @@ class Auth {
// TODO: Temporarily continue sending the old token as setToken
res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)
} else {
res.status(400).send('No callback or already expired')
Logger.error('[Auth] No callback or already expired')
res.redirect(`/login?error=${encodeURIComponent('No callback or already expired')}&autoLaunch=0`)
}
}
}
@@ -385,7 +387,13 @@ class Auth {
const sessionKey = this.oidcAuthStrategy.getStrategy()._key
if (!req.session[sessionKey]) {
return res.status(400).send('No session')
// Mobile clients send code_verifier and expect a status, not the web login page
const isMobile = req.cookies.auth_method === 'openid-mobile' || !!req.query.code_verifier
Logger.error('[Auth] /auth/openid/callback route: No session')
if (isMobile) {
return res.status(400).send('No session')
}
return res.redirect(`/login?error=${encodeURIComponent('No sign-in session')}&autoLaunch=0`)
}
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
+15 -3
View File
@@ -420,9 +420,21 @@ class Database {
*/
async createRootUser(username, pash, auth) {
if (!this.sequelize) return false
await this.userModel.createRootUser(username, pash, auth)
this.hasRootUser = true
return true
const transaction = await this.sequelize.transaction()
try {
const rootCount = await this.userModel.count({ where: { type: 'root' }, transaction })
if (rootCount > 0) {
await transaction.rollback()
return false
}
await this.userModel.createRootUser(username, pash, auth, transaction)
await transaction.commit()
this.hasRootUser = true
return true
} catch (error) {
await transaction.rollback()
throw error
}
}
updateServerSettings() {
+22 -2
View File
@@ -46,6 +46,22 @@ const passport = require('passport')
const expressSession = require('express-session')
const MemoryStore = require('./libs/memorystore')
/**
* Normalize to '' (root) or '/segment' with no trailing slash.
* Matches audiobookshelf-client-react `src/lib/basePath.ts`.
* @param {string|null|undefined} basePath
* @returns {string}
*/
function normalizeBasePath(basePath) {
if (!basePath) return ''
const trimmed = basePath.trim()
if (!trimmed || trimmed === '/') return ''
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
return withLeadingSlash.endsWith('/') ? withLeadingSlash.slice(0, -1) : withLeadingSlash
}
/**
* `next()` for a custom server is a NextCustomServer wrapper. After prepare(),
* `.server` is another NextServer wrapper; the Node server with `nextConfig` is
@@ -66,7 +82,7 @@ class Server {
global.isWin = process.platform === 'win32'
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH
global.RouterBasePath = normalizeBasePath(ROUTER_BASE_PATH)
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
@@ -472,7 +488,11 @@ class Server {
const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
await Database.createRootUser(rootUsername, rootPash, this.auth)
const created = await Database.createRootUser(rootUsername, rootPash, this.auth)
if (!created) {
Logger.error(`[Server] attempt to init server when server already has a root user`)
return res.sendStatus(500)
}
res.sendStatus(200)
}
+5 -1
View File
@@ -1,3 +1,4 @@
const { LRUCache } = require('lru-cache')
const { Request, Response } = require('express')
const passport = require('passport')
const OpenIDClient = require('openid-client')
@@ -15,7 +16,10 @@ class OidcAuthStrategy {
this.strategy = null
this.client = null
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
this.openIdAuthSession = new LRUCache({
ttl: 10 * 60 * 1000,
ttlAutopurge: true
})
}
/**
+1 -1
View File
@@ -44,7 +44,7 @@ class MiscController {
return res.sendStatus(400)
}
const files = Object.values(req.files)
const files = Object.values(req.files).flat()
let { title, author, series, folder: folderId, library: libraryId } = req.body
// Validate request body
if (!libraryId || !folderId || typeof libraryId !== 'string' || typeof folderId !== 'string' || !title || typeof title !== 'string') {
+12 -1
View File
@@ -2,6 +2,7 @@
const fs = require('graceful-fs')
const path = require('path')
const { pipeline } = require('stream')
const mkdirs = require('../mkdirs').mkdirs
const pathExists = require('../path-exists').pathExists
const utimesMillis = require('../util/utimes').utimesMillis
@@ -98,10 +99,20 @@ function mayCopyFile (srcStat, src, dest, opts, cb) {
}
function copyFile (srcStat, src, dest, opts, cb) {
fs.copyFile(src, dest, err => {
const finish = err => {
if (err) return cb(err)
if (opts.preserveTimestamps) return handleTimestampsAndMode(srcStat.mode, src, dest, cb)
return setDestMode(dest, srcStat.mode, cb)
}
fs.copyFile(src, dest, err => {
if (err?.code !== 'EPERM' || !srcStat.isFile()) return finish(err)
// Stream the fallback so database copies do not require a full-file buffer.
pipeline(fs.createReadStream(src, { highWaterMark: 1024 * 1024 }), fs.createWriteStream(dest, { highWaterMark: 1024 * 1024 }), streamError => {
if (streamError) return fs.unlink(dest, () => cb(streamError))
finish()
})
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ function doRename (src, dest, overwrite, isChangingCase, cb) {
function rename (src, dest, overwrite, cb) {
fs.rename(src, dest, err => {
if (!err) return cb()
if (err.code !== 'EXDEV') return cb(err)
if (err.code !== 'EXDEV' && err.code !== 'EPERM') return cb(err)
return moveAcrossDevice(src, dest, overwrite, cb)
})
}
+15 -3
View File
@@ -87,6 +87,18 @@ class Feed extends Model {
)
}
/**
* @param {string} slug
* @param {string|null|undefined} coverPath
* @param {Date|null|undefined} entityUpdatedAt
* @returns {string}
*/
static getFeedImageURL(slug, coverPath, entityUpdatedAt) {
if (!coverPath) return '/Logo.png'
const cacheBuster = entityUpdatedAt != null ? `?ts=${entityUpdatedAt.valueOf()}` : ''
return `/feed/${slug}/cover${Path.extname(coverPath)}${cacheBuster}`
}
/**
*
* @param {string} userId
@@ -119,7 +131,7 @@ class Feed extends Model {
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
imageURL: Feed.getFeedImageURL(slug, media.coverPath, entityUpdatedAt),
siteURL: `/item/${libraryItem.id}`,
title: media.title,
description: media.description,
@@ -212,7 +224,7 @@ class Feed extends Model {
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
imageURL: Feed.getFeedImageURL(slug, firstBookWithCover?.coverPath, entityUpdatedAt),
siteURL: `/collection/${collectionExpanded.id}`,
title: collectionExpanded.name,
description: collectionExpanded.description || '',
@@ -301,7 +313,7 @@ class Feed extends Model {
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
imageURL: Feed.getFeedImageURL(slug, firstBookWithCover?.coverPath, entityUpdatedAt),
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
title: seriesExpanded.name,
description: seriesExpanded.description || '',
+2 -2
View File
@@ -189,7 +189,7 @@ class User extends Model {
* @param {import('../Auth')} auth
* @returns {Promise<User>}
*/
static async createRootUser(username, pash, auth) {
static async createRootUser(username, pash, auth, transaction = null) {
const userId = uuidv4()
const token = auth.generateAccessToken({ id: userId, username })
@@ -207,7 +207,7 @@ class User extends Model {
seriesHideFromContinueListening: []
}
}
return this.create(newUser)
return this.create(newUser, { transaction })
}
/**
+1 -1
View File
@@ -160,7 +160,7 @@ class Audible {
timeout
})
.then((res) => {
if (!res?.data?.products) return null
if (!res?.data?.products) return []
return Promise.all(res.data.products.map((result) => this.asinSearch(result.asin, region, timeout)))
})
.catch((error) => {
+23
View File
@@ -75,3 +75,26 @@ describe('OidcAuthStrategy - isValidWebCallbackUrl', () => {
expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false)
})
})
describe('OidcAuthStrategy - openIdAuthSession', () => {
/** @type {OidcAuthStrategy} */
let strategy
beforeEach(() => {
global.RouterBasePath = ''
strategy = new OidcAuthStrategy()
})
it('configures a 10 minute TTL with autopurge so abandoned mobile logins are cleaned up', () => {
expect(strategy.openIdAuthSession.ttl).to.equal(10 * 60 * 1000)
expect(strategy.openIdAuthSession.ttlAutopurge).to.equal(true)
})
it('still supports the same set/has/get/delete usage as a plain Map', () => {
strategy.openIdAuthSession.set('some-state', { mobile_redirect_uri: 'myapp://callback' })
expect(strategy.openIdAuthSession.has('some-state')).to.equal(true)
expect(strategy.openIdAuthSession.get('some-state')).to.deep.equal({ mobile_redirect_uri: 'myapp://callback' })
strategy.openIdAuthSession.delete('some-state')
expect(strategy.openIdAuthSession.has('some-state')).to.equal(false)
})
})
+96
View File
@@ -0,0 +1,96 @@
const { expect } = require('chai')
const sinon = require('sinon')
const fs = require('graceful-fs')
const os = require('os')
const path = require('path')
const fsExtra = require('../../server/libs/fsExtra')
describe('fsExtra copy fallback', () => {
let dir
let source
let dest
let contents
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-copy-'))
source = path.join(dir, 'source')
dest = path.join(dir, 'dest')
contents = Buffer.alloc(2 * 1024 * 1024 + 17, 0x61)
fs.writeFileSync(source, contents)
})
afterEach(() => {
sinon.restore()
fs.rmSync(dir, { recursive: true, force: true })
})
it('copies through streams when asynchronous copyFile is denied', async () => {
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.copy(source, dest)
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('moves across devices when asynchronous copyFile is denied', async () => {
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
callback(Object.assign(new Error('cross-device link'), { code: 'EXDEV' }))
})
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.move(source, dest)
expect(fs.existsSync(source)).to.be.false
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('moves over an existing file when asynchronous rename and copyFile are denied', async () => {
fs.writeFileSync(dest, 'old database')
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
callback(Object.assign(new Error('rename denied'), { code: 'EPERM' }))
})
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.move(source, dest, { overwrite: true })
expect(fs.existsSync(source)).to.be.false
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('keeps the source when a move cannot copy it', async () => {
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
callback(Object.assign(new Error('rename denied'), { code: 'EPERM' }))
})
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('read failed'), { code: 'EIO' }))
})
try {
await fsExtra.move(source, dest)
expect.fail('Expected move to fail')
} catch (error) {
expect(error.code).to.equal('EIO')
}
expect(fs.readFileSync(source).equals(contents)).to.be.true
})
it('propagates unrelated copy errors', async () => {
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('file missing'), { code: 'ENOENT' }))
})
try {
await fsExtra.copy(source, dest)
expect.fail('Expected copy to fail')
} catch (error) {
expect(error.code).to.equal('ENOENT')
}
expect(fs.existsSync(dest)).to.be.false
})
})