diff --git a/client/package-lock.json b/client/package-lock.json
index a85ac544..e5de4940 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
- "version": "2.0.14",
+ "version": "2.0.17",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
- "version": "2.0.14",
+ "version": "2.0.17",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
@@ -18,6 +18,7 @@
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
"nuxt-socket-io": "^1.1.18",
+ "trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.3.0",
"vue-toastification": "^1.7.11",
@@ -15285,6 +15286,11 @@
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
},
+ "node_modules/trix": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz",
+ "integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA=="
+ },
"node_modules/ts-pnp": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
@@ -29080,6 +29086,11 @@
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
},
+ "trix": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz",
+ "integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA=="
+ },
"ts-pnp": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
diff --git a/client/package.json b/client/package.json
index 35541e86..b1eee263 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
- "version": "2.0.15",
+ "version": "2.0.17",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -22,6 +22,7 @@
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
"nuxt-socket-io": "^1.1.18",
+ "trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.3.0",
"vue-toastification": "^1.7.11",
@@ -32,4 +33,4 @@
"@nuxtjs/tailwindcss": "^4.2.1",
"postcss": "^8.3.6"
}
-}
\ No newline at end of file
+}
diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue
index ea8fa32e..9f37cfb5 100644
--- a/client/pages/config/stats.vue
+++ b/client/pages/config/stats.vue
@@ -37,7 +37,11 @@
-
Recent Listening Sessions
+
+
Recent Sessions
+
+
View All
+
No Listening Sessions
diff --git a/client/pages/config/users/_id.vue b/client/pages/config/users/_id/index.vue
similarity index 90%
rename from client/pages/config/users/_id.vue
rename to client/pages/config/users/_id/index.vue
index b44bd900..98961b33 100644
--- a/client/pages/config/users/_id.vue
+++ b/client/pages/config/users/_id/index.vue
@@ -22,6 +22,10 @@
Listening Stats
+
+
{{ listeningSessions.length }} Listening Sessions
+
View All
+
Total Time Listened:
{{ listeningTimePretty }}
@@ -33,12 +37,14 @@
Last Listening Session
-
{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}
+
+ {{ latestSession.displayTitle }} {{ $dateDistanceFromNow(latestSession.updatedAt) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}
+
-
Item Progress
+
Saved Media Progress
-
Nothing read yet...
+
Nothing listened to yet...
diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue
new file mode 100644
index 00000000..93395bfc
--- /dev/null
+++ b/client/pages/config/users/_id/sessions.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+ arrow_back
+
+
Back to User
+
+
+
+
+
{{ username }}
+
+
+
+
+
+
Listening Sessions ({{ listeningSessions.length }})
+
+
+ Item |
+ Play Method |
+ Device Info |
+ Listened |
+ Last Time |
+ Last Update |
+
+
+
+ {{ session.displayTitle }}
+ {{ session.displayAuthor }}
+ |
+
+ {{ getPlayMethodName(session.playMethod) }}
+ |
+
+
+ |
+
+ {{ $elapsedPretty(session.timeListening) }}
+ |
+
+ {{ $secondsToTimestamp(session.currentTime) }}
+ |
+
+
+ {{ $dateDistanceFromNow(session.updatedAt) }}
+
+ |
+
+
+
No sessions yet...
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/plugins/constants.js b/client/plugins/constants.js
index 7712c680..79ad488f 100644
--- a/client/plugins/constants.js
+++ b/client/plugins/constants.js
@@ -28,7 +28,8 @@ const BookshelfView = {
const PlayMethod = {
DIRECTPLAY: 0,
DIRECTSTREAM: 1,
- TRANSCODE: 2
+ TRANSCODE: 2,
+ LOCAL: 3
}
const Constants = {
diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js
index 0fab85aa..bf3a6734 100644
--- a/client/plugins/init.client.js
+++ b/client/plugins/init.client.js
@@ -57,6 +57,7 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
+ if (!seconds) return '0:00'
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
diff --git a/client/store/globals.js b/client/store/globals.js
index bbc383dd..16d1ef91 100644
--- a/client/store/globals.js
+++ b/client/store/globals.js
@@ -6,6 +6,7 @@ export const state = () => ({
showUserCollectionsModal: false,
showEditCollectionModal: false,
showEditPodcastEpisode: false,
+ showViewPodcastEpisodeModal: false,
showEditAuthorModal: false,
selectedEpisode: null,
selectedCollection: null,
@@ -53,6 +54,9 @@ export const mutations = {
setShowEditPodcastEpisodeModal(state, val) {
state.showEditPodcastEpisode = val
},
+ setShowViewPodcastEpisodeModal(state, val) {
+ state.showViewPodcastEpisodeModal = val
+ },
setEditCollection(state, collection) {
state.selectedCollection = collection
state.showEditCollectionModal = true
diff --git a/package-lock.json b/package-lock.json
index 67d3c687..86ebcc86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,11 @@
{
"name": "audiobookshelf",
- "version": "2.0.14",
+ "version": "2.0.17",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
- "name": "audiobookshelf",
- "version": "2.0.14",
+ "version": "2.0.17",
"license": "GPL-3.0",
"dependencies": {
"archiver": "^5.3.0",
@@ -20,6 +19,7 @@
"fast-sort": "^3.1.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
+ "htmlparser2": "^8.0.1",
"image-type": "^4.1.0",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
@@ -31,24 +31,11 @@
"read-chunk": "^3.1.0",
"recursive-readdir-async": "^1.1.8",
"socket.io": "^4.4.1",
- "string-strip-html": "^8.3.0",
"watcher": "^1.2.0",
"xml2js": "^0.4.23"
},
"bin": {
"audiobookshelf": "prod.js"
- },
- "devDependencies": {}
- },
- "node_modules/@babel/runtime": {
- "version": "7.17.9",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz",
- "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==",
- "dependencies": {
- "regenerator-runtime": "^0.13.4"
- },
- "engines": {
- "node": ">=6.9.0"
}
},
"node_modules/@sindresorhus/is": {
@@ -625,6 +612,57 @@
"node": ">=4.5.0"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ]
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
+ "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -711,6 +749,17 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/entities": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.3.0.tgz",
+ "integrity": "sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -994,10 +1043,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/html-entities": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
- "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="
+ "node_modules/htmlparser2": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
+ "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "entities": "^4.3.0"
+ }
},
"node_modules/http-cache-semantics": {
"version": "4.1.0",
@@ -1225,11 +1287,6 @@
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
},
- "node_modules/lodash.clonedeep": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
- "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
- },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -1280,21 +1337,11 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
- "node_modules/lodash.trim": {
- "version": "4.5.1",
- "resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz",
- "integrity": "sha1-NkJefukL5KpeJ7zruFt9EepHqlc="
- },
"node_modules/lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg="
},
- "node_modules/lodash.without": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz",
- "integrity": "sha1-PNRXSgC2e643OpS3SHcmQFB7eqw="
- },
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -1625,44 +1672,6 @@
"node": ">= 0.6"
}
},
- "node_modules/ranges-apply": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-5.1.0.tgz",
- "integrity": "sha512-VF3a0XUuYS/BQHv2RaIyX1K7S1hbfrs64hkGKgPVk0Y7p4XFwSucjTTttrBqmkcmB/PZx5ISTZdxErRZi/89aQ==",
- "dependencies": {
- "@babel/runtime": "^7.14.0",
- "ranges-merge": "^7.1.0"
- }
- },
- "node_modules/ranges-merge": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-7.1.0.tgz",
- "integrity": "sha512-coTHcyAEIhoEdsBs9f5f+q0rmy7UHvS/5nfuXzuj5oLX/l/tbqM5uxRb6eh8WMdetXia3lK67ZO4tarH4ieulQ==",
- "dependencies": {
- "@babel/runtime": "^7.14.0",
- "ranges-push": "^5.1.0",
- "ranges-sort": "^4.1.0"
- }
- },
- "node_modules/ranges-push": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-5.1.0.tgz",
- "integrity": "sha512-vqGcaGq7GWV1zBa9w83E+dzYkOvE9/3pIRUPvLf12c+mGQCf1nesrkBI7Ob8taN2CC9V1HDSJx0KAQl0SgZftA==",
- "dependencies": {
- "@babel/runtime": "^7.14.0",
- "ranges-merge": "^7.1.0",
- "string-collapse-leading-whitespace": "^5.1.0",
- "string-trim-spaces-only": "^3.1.0"
- }
- },
- "node_modules/ranges-sort": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-4.1.0.tgz",
- "integrity": "sha512-GOQgk6UtsrfKFeYa53YLiBVnLINwYmOk5l2QZG1csZpT6GdImUwooh+/cRrp7b+fYawZX/rnyA3Ul+pdgQBIzA==",
- "dependencies": {
- "@babel/runtime": "^7.14.0"
- }
- },
"node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
@@ -1718,11 +1727,6 @@
"node": ">=7.6"
}
},
- "node_modules/regenerator-runtime": {
- "version": "0.13.9",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
- "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
- },
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
@@ -1987,52 +1991,11 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
- "node_modules/string-collapse-leading-whitespace": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-5.1.0.tgz",
- "integrity": "sha512-mYz9/Kb5uvRB4DZj46zILwI4y9lD9JsvXG9Xb7zjbwm0I/R40G7oFfMsqJ28l2d7gWMTLJL569NfJQVLQbnHCw==",
- "dependencies": {
- "@babel/runtime": "^7.14.0"
- }
- },
"node_modules/string-indexes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz",
"integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw=="
},
- "node_modules/string-left-right": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-4.1.0.tgz",
- "integrity": "sha512-ic/WvfNVUygWWsgg8akzSzp2NuttfhrdbH7QmSnda5b5RFmT9aCEDiS/M+gmTJwtFy7+b/2AXU4Z6vejcePQqQ==",
- "dependencies": {
- "@babel/runtime": "^7.14.0",
- "lodash.clonedeep": "^4.5.0",
- "lodash.isplainobject": "^4.0.6"
- }
- },
- "node_modules/string-strip-html": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-8.3.0.tgz",
- "integrity": "sha512-1+rjTPt0JjpFr1w0bfNL1S6O0I9fJDqM+P3pFTpC6eEEpIXhmBvPLnaQoEuWarswiH219qCefDSxTLxGQyHKUg==",
- "dependencies": {
- "@babel/runtime": "^7.14.0",
- "html-entities": "^2.3.2",
- "lodash.isplainobject": "^4.0.6",
- "lodash.trim": "^4.5.1",
- "lodash.without": "^4.4.0",
- "ranges-apply": "^5.1.0",
- "ranges-push": "^5.1.0",
- "string-left-right": "^4.1.0"
- }
- },
- "node_modules/string-trim-spaces-only": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-3.1.0.tgz",
- "integrity": "sha512-AW7RSi3+QtE6wR+4m/kmwlyy39neBbCIzrzzu1/RGzNRiPKQOeB3rGzr4ubg4UIQgYtr2w0PrxhKPXgyqJ0vaQ==",
- "dependencies": {
- "@babel/runtime": "^7.14.0"
- }
- },
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -2229,14 +2192,6 @@
}
},
"dependencies": {
- "@babel/runtime": {
- "version": "7.17.9",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz",
- "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==",
- "requires": {
- "regenerator-runtime": "^0.13.4"
- }
- },
"@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -2683,6 +2638,39 @@
"streamsearch": "0.1.2"
}
},
+ "dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ }
+ },
+ "domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
+ },
+ "domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "requires": {
+ "domelementtype": "^2.3.0"
+ }
+ },
+ "domutils": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
+ "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+ "requires": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.1"
+ }
+ },
"ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -2751,6 +2739,11 @@
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz",
"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg=="
},
+ "entities": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.3.0.tgz",
+ "integrity": "sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg=="
+ },
"escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -2960,10 +2953,16 @@
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
},
- "html-entities": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
- "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="
+ "htmlparser2": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
+ "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "entities": "^4.3.0"
+ }
},
"http-cache-semantics": {
"version": "4.1.0",
@@ -3154,11 +3153,6 @@
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
},
- "lodash.clonedeep": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
- "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
- },
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -3209,21 +3203,11 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
- "lodash.trim": {
- "version": "4.5.1",
- "resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz",
- "integrity": "sha1-NkJefukL5KpeJ7zruFt9EepHqlc="
- },
"lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg="
},
- "lodash.without": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz",
- "integrity": "sha1-PNRXSgC2e643OpS3SHcmQFB7eqw="
- },
"lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -3451,44 +3435,6 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
},
- "ranges-apply": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-5.1.0.tgz",
- "integrity": "sha512-VF3a0XUuYS/BQHv2RaIyX1K7S1hbfrs64hkGKgPVk0Y7p4XFwSucjTTttrBqmkcmB/PZx5ISTZdxErRZi/89aQ==",
- "requires": {
- "@babel/runtime": "^7.14.0",
- "ranges-merge": "^7.1.0"
- }
- },
- "ranges-merge": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-7.1.0.tgz",
- "integrity": "sha512-coTHcyAEIhoEdsBs9f5f+q0rmy7UHvS/5nfuXzuj5oLX/l/tbqM5uxRb6eh8WMdetXia3lK67ZO4tarH4ieulQ==",
- "requires": {
- "@babel/runtime": "^7.14.0",
- "ranges-push": "^5.1.0",
- "ranges-sort": "^4.1.0"
- }
- },
- "ranges-push": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-5.1.0.tgz",
- "integrity": "sha512-vqGcaGq7GWV1zBa9w83E+dzYkOvE9/3pIRUPvLf12c+mGQCf1nesrkBI7Ob8taN2CC9V1HDSJx0KAQl0SgZftA==",
- "requires": {
- "@babel/runtime": "^7.14.0",
- "ranges-merge": "^7.1.0",
- "string-collapse-leading-whitespace": "^5.1.0",
- "string-trim-spaces-only": "^3.1.0"
- }
- },
- "ranges-sort": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-4.1.0.tgz",
- "integrity": "sha512-GOQgk6UtsrfKFeYa53YLiBVnLINwYmOk5l2QZG1csZpT6GdImUwooh+/cRrp7b+fYawZX/rnyA3Ul+pdgQBIzA==",
- "requires": {
- "@babel/runtime": "^7.14.0"
- }
- },
"raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
@@ -3532,11 +3478,6 @@
"resolved": "https://registry.npmjs.org/recursive-readdir-async/-/recursive-readdir-async-1.2.1.tgz",
"integrity": "sha512-fU8aySmHIhrycTlXn+hI7dS/p7GnrMHzr2xDdBSd8HZ16mbLkmfIEccIE80gLHftrkTt9oDJiGEJNIPY6n0v6A=="
},
- "regenerator-runtime": {
- "version": "0.13.9",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
- "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
- },
"resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
@@ -3748,52 +3689,11 @@
}
}
},
- "string-collapse-leading-whitespace": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-5.1.0.tgz",
- "integrity": "sha512-mYz9/Kb5uvRB4DZj46zILwI4y9lD9JsvXG9Xb7zjbwm0I/R40G7oFfMsqJ28l2d7gWMTLJL569NfJQVLQbnHCw==",
- "requires": {
- "@babel/runtime": "^7.14.0"
- }
- },
"string-indexes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz",
"integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw=="
},
- "string-left-right": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-4.1.0.tgz",
- "integrity": "sha512-ic/WvfNVUygWWsgg8akzSzp2NuttfhrdbH7QmSnda5b5RFmT9aCEDiS/M+gmTJwtFy7+b/2AXU4Z6vejcePQqQ==",
- "requires": {
- "@babel/runtime": "^7.14.0",
- "lodash.clonedeep": "^4.5.0",
- "lodash.isplainobject": "^4.0.6"
- }
- },
- "string-strip-html": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-8.3.0.tgz",
- "integrity": "sha512-1+rjTPt0JjpFr1w0bfNL1S6O0I9fJDqM+P3pFTpC6eEEpIXhmBvPLnaQoEuWarswiH219qCefDSxTLxGQyHKUg==",
- "requires": {
- "@babel/runtime": "^7.14.0",
- "html-entities": "^2.3.2",
- "lodash.isplainobject": "^4.0.6",
- "lodash.trim": "^4.5.1",
- "lodash.without": "^4.4.0",
- "ranges-apply": "^5.1.0",
- "ranges-push": "^5.1.0",
- "string-left-right": "^4.1.0"
- }
- },
- "string-trim-spaces-only": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-3.1.0.tgz",
- "integrity": "sha512-AW7RSi3+QtE6wR+4m/kmwlyy39neBbCIzrzzu1/RGzNRiPKQOeB3rGzr4ubg4UIQgYtr2w0PrxhKPXgyqJ0vaQ==",
- "requires": {
- "@babel/runtime": "^7.14.0"
- }
- },
"tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
diff --git a/package.json b/package.json
index babba376..fab42a18 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.0.15",
+ "version": "2.0.17",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -38,6 +38,7 @@
"fast-sort": "^3.1.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
+ "htmlparser2": "^8.0.1",
"image-type": "^4.1.0",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
@@ -49,8 +50,7 @@
"read-chunk": "^3.1.0",
"recursive-readdir-async": "^1.1.8",
"socket.io": "^4.4.1",
- "string-strip-html": "^8.3.0",
"watcher": "^1.2.0",
"xml2js": "^0.4.23"
}
-}
\ No newline at end of file
+}
diff --git a/server/Watcher.js b/server/Watcher.js
index 555dce06..d2166b6e 100644
--- a/server/Watcher.js
+++ b/server/Watcher.js
@@ -162,13 +162,6 @@ class FolderWatcher extends EventEmitter {
}
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
- // Check if file was added to root directory
- var dir = Path.dirname(path)
- if (dir === folderFullPath) {
- Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`)
- return
- }
-
var relPath = path.replace(folderFullPath, '')
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 6765e5f1..9bd34c75 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -189,8 +189,8 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404)
}
- const options = req.body || {}
- this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, null, options, res)
+
+ this.playbackSessionManager.startSessionRequest(req, res, null)
}
// POST: api/items/:id/play/:episodeId
@@ -206,8 +206,7 @@ class LibraryItemController {
return res.sendStatus(404)
}
- const options = req.body || {}
- this.playbackSessionManager.startSessionRequest(req.user, libraryItem, episodeId, options, res)
+ this.playbackSessionManager.startSessionRequest(req, res, episodeId)
}
// PATCH: api/items/:id/tracks
@@ -224,38 +223,6 @@ class LibraryItemController {
res.json(libraryItem.toJSON())
}
- // PATCH: api/items/:id/episodes
- async updateEpisodes(req, res) { // For updating podcast episode order
- var libraryItem = req.libraryItem
- var orderedFileData = req.body.episodes
- if (!libraryItem.media.setEpisodeOrder) {
- Logger.error(`[LibraryItemController] updateEpisodes invalid media type ${libraryItem.id}`)
- return res.sendStatus(500)
- }
- libraryItem.media.setEpisodeOrder(orderedFileData)
- await this.db.updateLibraryItem(libraryItem)
- this.emitter('item_updated', libraryItem.toJSONExpanded())
- res.json(libraryItem.toJSON())
- }
-
- // DELETE: api/items/:id/episode/:episodeId
- async removeEpisode(req, res) {
- var episodeId = req.params.episodeId
- var libraryItem = req.libraryItem
- if (libraryItem.mediaType !== 'podcast') {
- Logger.error(`[LibraryItemController] removeEpisode invalid media type ${libraryItem.id}`)
- return res.sendStatus(500)
- }
- if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
- Logger.error(`[LibraryItemController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
- return res.sendStatus(404)
- }
- libraryItem.media.removeEpisode(episodeId)
- await this.db.updateLibraryItem(libraryItem)
- this.emitter('item_updated', libraryItem.toJSONExpanded())
- res.json(libraryItem.toJSON())
- }
-
// POST api/items/:id/match
async match(req, res) {
var libraryItem = req.libraryItem
diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index e83e108f..41ab8f11 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -109,10 +109,8 @@ class PodcastController {
return res.status(500).send('Invalid podcast RSS feed')
}
- if (!payload.podcast.metadata.feedUrl) {
- // Not every RSS feed will put the feed url in their metadata
- payload.podcast.metadata.feedUrl = url
- }
+ // RSS feed may be a private RSS feed
+ payload.podcast.metadata.feedUrl = url
res.json(payload)
}).catch((error) => {
@@ -190,6 +188,35 @@ class PodcastController {
res.json(libraryItem.toJSONExpanded())
}
+ // DELETE: api/podcasts/:id/episode/:episodeId
+ async removeEpisode(req, res) {
+ var episodeId = req.params.episodeId
+ var libraryItem = req.libraryItem
+ var hardDelete = req.query.hard === '1'
+
+ var episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
+ if (!episode) {
+ Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
+ return res.sendStatus(404)
+ }
+
+ if (hardDelete) {
+ var audioFile = episode.audioFile
+ // TODO: this will trigger the watcher. should maybe handle this gracefully
+ await fs.remove(audioFile.metadata.path).then(() => {
+ Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
+ }).catch((error) => {
+ Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
+ })
+ }
+
+ libraryItem.media.removeEpisode(episodeId)
+
+ await this.db.updateLibraryItem(libraryItem)
+ this.emitter('item_updated', libraryItem.toJSONExpanded())
+ res.json(libraryItem.toJSON())
+ }
+
middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
diff --git a/server/libs/isJs.js b/server/libs/isJs.js
new file mode 100644
index 00000000..5f4c3439
--- /dev/null
+++ b/server/libs/isJs.js
@@ -0,0 +1,5 @@
+/*!
+ * is.js 0.9.0
+ * Author: Aras Atasaygin
+ */
+(function (n, t) { if (typeof define === "function" && define.amd) { define(function () { return n.is = t() }) } else if (typeof exports === "object") { module.exports = t() } else { n.is = t() } })(this, function () { var n = {}; n.VERSION = "0.8.0"; n.not = {}; n.all = {}; n.any = {}; var t = Object.prototype.toString; var e = Array.prototype.slice; var r = Object.prototype.hasOwnProperty; function a(n) { return function () { return !n.apply(null, e.call(arguments)) } } function u(n) { return function () { var t = c(arguments); var e = t.length; for (var r = 0; r < e; r++) { if (!n.call(null, t[r])) { return false } } return true } } function o(n) { return function () { var t = c(arguments); var e = t.length; for (var r = 0; r < e; r++) { if (n.call(null, t[r])) { return true } } return false } } var i = { "<": function (n, t) { return n < t }, "<=": function (n, t) { return n <= t }, ">": function (n, t) { return n > t }, ">=": function (n, t) { return n >= t } }; function f(n, t) { var e = t + ""; var r = +(e.match(/\d+/) || NaN); var a = e.match(/^[<>]=?|/)[0]; return i[a] ? i[a](n, r) : n == r || r !== r } function c(t) { var r = e.call(t); var a = r.length; if (a === 1 && n.array(r[0])) { r = r[0] } return r } n.arguments = function (n) { return t.call(n) === "[object Arguments]" || n != null && typeof n === "object" && "callee" in n }; n.array = Array.isArray || function (n) { return t.call(n) === "[object Array]" }; n.boolean = function (n) { return n === true || n === false || t.call(n) === "[object Boolean]" }; n.char = function (t) { return n.string(t) && t.length === 1 }; n.date = function (n) { return t.call(n) === "[object Date]" }; n.domNode = function (t) { return n.object(t) && t.nodeType > 0 }; n.error = function (n) { return t.call(n) === "[object Error]" }; n["function"] = function (n) { return t.call(n) === "[object Function]" || typeof n === "function" }; n.json = function (n) { return t.call(n) === "[object Object]" }; n.nan = function (n) { return n !== n }; n["null"] = function (n) { return n === null }; n.number = function (e) { return n.not.nan(e) && t.call(e) === "[object Number]" }; n.object = function (n) { return Object(n) === n }; n.regexp = function (n) { return t.call(n) === "[object RegExp]" }; n.sameType = function (e, r) { var a = t.call(e); if (a !== t.call(r)) { return false } if (a === "[object Number]") { return !n.any.nan(e, r) || n.all.nan(e, r) } return true }; n.sameType.api = ["not"]; n.string = function (n) { return t.call(n) === "[object String]" }; n.undefined = function (n) { return n === void 0 }; n.windowObject = function (n) { return n != null && typeof n === "object" && "setInterval" in n }; n.empty = function (t) { if (n.object(t)) { var e = Object.getOwnPropertyNames(t).length; if (e === 0 || e === 1 && n.array(t) || e === 2 && n.arguments(t)) { return true } return false } return t === "" }; n.existy = function (n) { return n != null }; n.falsy = function (n) { return !n }; n.truthy = a(n.falsy); n.above = function (t, e) { return n.all.number(t, e) && t > e }; n.above.api = ["not"]; n.decimal = function (t) { return n.number(t) && t % 1 !== 0 }; n.equal = function (t, e) { if (n.all.number(t, e)) { return t === e && 1 / t === 1 / e } if (n.all.string(t, e) || n.all.regexp(t, e)) { return "" + t === "" + e } if (n.all.boolean(t, e)) { return t === e } return false }; n.equal.api = ["not"]; n.even = function (t) { return n.number(t) && t % 2 === 0 }; n.finite = isFinite || function (t) { return n.not.infinite(t) && n.not.nan(t) }; n.infinite = function (n) { return n === Infinity || n === -Infinity }; n.integer = function (t) { return n.number(t) && t % 1 === 0 }; n.negative = function (t) { return n.number(t) && t < 0 }; n.odd = function (t) { return n.number(t) && t % 2 === 1 }; n.positive = function (t) { return n.number(t) && t > 0 }; n.under = function (t, e) { return n.all.number(t, e) && t < e }; n.under.api = ["not"]; n.within = function (t, e, r) { return n.all.number(t, e, r) && t > e && t < r }; n.within.api = ["not"]; var l = { affirmative: /^(?:1|t(?:rue)?|y(?:es)?|ok(?:ay)?)$/, alphaNumeric: /^[A-Za-z0-9]+$/, caPostalCode: /^(?!.*[DFIOQU])[A-VXY][0-9][A-Z]\s?[0-9][A-Z][0-9]$/, creditCard: /^(?:(4[0-9]{12}(?:[0-9]{3})?)|(5[1-5][0-9]{14})|(6(?:011|5[0-9]{2})[0-9]{12})|(3[47][0-9]{13})|(3(?:0[0-5]|[68][0-9])[0-9]{11})|((?:2131|1800|35[0-9]{3})[0-9]{11}))$/, dateString: /^(1[0-2]|0?[1-9])([\/-])(3[01]|[12][0-9]|0?[1-9])(?:\2)(?:[0-9]{2})?[0-9]{2}$/, email: /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i, eppPhone: /^\+[0-9]{1,3}\.[0-9]{4,14}(?:x.+)?$/, hexadecimal: /^(?:0x)?[0-9a-fA-F]+$/, hexColor: /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, ipv4: /^(?:(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.){3}(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$/, ipv6: /^((?=.*::)(?!.*::.+::)(::)?([\dA-F]{1,4}:(:|\b)|){5}|([\dA-F]{1,4}:){6})((([\dA-F]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/i, nanpPhone: /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, socialSecurityNumber: /^(?!000|666)[0-8][0-9]{2}-?(?!00)[0-9]{2}-?(?!0000)[0-9]{4}$/, timeString: /^(2[0-3]|[01]?[0-9]):([0-5]?[0-9]):([0-5]?[0-9])$/, ukPostCode: /^[A-Z]{1,2}[0-9RCHNQ][0-9A-Z]?\s?[0-9][ABD-HJLNP-UW-Z]{2}$|^[A-Z]{2}-?[0-9]{4}$/, url: /^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/i, usZipCode: /^[0-9]{5}(?:-[0-9]{4})?$/ }; function d(t, e) { n[t] = function (n) { return e[t].test(n) } } for (var s in l) { if (l.hasOwnProperty(s)) { d(s, l) } } n.ip = function (t) { return n.ipv4(t) || n.ipv6(t) }; n.capitalized = function (t) { if (n.not.string(t)) { return false } var e = t.split(" "); for (var r = 0; r < e.length; r++) { var a = e[r]; if (a.length) { var u = a.charAt(0); if (u !== u.toUpperCase()) { return false } } } return true }; n.endWith = function (t, e) { if (n.not.string(t)) { return false } e += ""; var r = t.length - e.length; return r >= 0 && t.indexOf(e, r) === r }; n.endWith.api = ["not"]; n.include = function (n, t) { return n.indexOf(t) > -1 }; n.include.api = ["not"]; n.lowerCase = function (t) { return n.string(t) && t === t.toLowerCase() }; n.palindrome = function (t) { if (n.not.string(t)) { return false } t = t.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase(); var e = t.length - 1; for (var r = 0, a = Math.floor(e / 2); r <= a; r++) { if (t.charAt(r) !== t.charAt(e - r)) { return false } } return true }; n.space = function (t) { if (n.not.char(t)) { return false } var e = t.charCodeAt(0); return e > 8 && e < 14 || e === 32 }; n.startWith = function (t, e) { return n.string(t) && t.indexOf(e) === 0 }; n.startWith.api = ["not"]; n.upperCase = function (t) { return n.string(t) && t === t.toUpperCase() }; var F = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; var p = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; n.day = function (t, e) { return n.date(t) && e.toLowerCase() === F[t.getDay()] }; n.day.api = ["not"]; n.dayLightSavingTime = function (n) { var t = new Date(n.getFullYear(), 0, 1); var e = new Date(n.getFullYear(), 6, 1); var r = Math.max(t.getTimezoneOffset(), e.getTimezoneOffset()); return n.getTimezoneOffset() < r }; n.future = function (t) { var e = new Date; return n.date(t) && t.getTime() > e.getTime() }; n.inDateRange = function (t, e, r) { if (n.not.date(t) || n.not.date(e) || n.not.date(r)) { return false } var a = t.getTime(); return a > e.getTime() && a < r.getTime() }; n.inDateRange.api = ["not"]; n.inLastMonth = function (t) { return n.inDateRange(t, new Date((new Date).setMonth((new Date).getMonth() - 1)), new Date) }; n.inLastWeek = function (t) { return n.inDateRange(t, new Date((new Date).setDate((new Date).getDate() - 7)), new Date) }; n.inLastYear = function (t) { return n.inDateRange(t, new Date((new Date).setFullYear((new Date).getFullYear() - 1)), new Date) }; n.inNextMonth = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setMonth((new Date).getMonth() + 1))) }; n.inNextWeek = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setDate((new Date).getDate() + 7))) }; n.inNextYear = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setFullYear((new Date).getFullYear() + 1))) }; n.leapYear = function (t) { return n.number(t) && (t % 4 === 0 && t % 100 !== 0 || t % 400 === 0) }; n.month = function (t, e) { return n.date(t) && e.toLowerCase() === p[t.getMonth()] }; n.month.api = ["not"]; n.past = function (t) { var e = new Date; return n.date(t) && t.getTime() < e.getTime() }; n.quarterOfYear = function (t, e) { return n.date(t) && n.number(e) && e === Math.floor((t.getMonth() + 3) / 3) }; n.quarterOfYear.api = ["not"]; n.today = function (t) { var e = new Date; var r = e.toDateString(); return n.date(t) && t.toDateString() === r }; n.tomorrow = function (t) { var e = new Date; var r = new Date(e.setDate(e.getDate() + 1)).toDateString(); return n.date(t) && t.toDateString() === r }; n.weekend = function (t) { return n.date(t) && (t.getDay() === 6 || t.getDay() === 0) }; n.weekday = a(n.weekend); n.year = function (t, e) { return n.date(t) && n.number(e) && e === t.getFullYear() }; n.year.api = ["not"]; n.yesterday = function (t) { var e = new Date; var r = new Date(e.setDate(e.getDate() - 1)).toDateString(); return n.date(t) && t.toDateString() === r }; var D = n.windowObject(typeof global == "object" && global) && global; var h = n.windowObject(typeof self == "object" && self) && self; var v = n.windowObject(typeof this == "object" && this) && this; var b = D || h || v || Function("return this")(); var g = h && h.document; var m = b.is; var w = h && h.navigator; var y = (w && w.appVersion || "").toLowerCase(); var x = (w && w.userAgent || "").toLowerCase(); var A = (w && w.vendor || "").toLowerCase(); n.android = function () { return /android/.test(x) }; n.android.api = ["not"]; n.androidPhone = function () { return /android/.test(x) && /mobile/.test(x) }; n.androidPhone.api = ["not"]; n.androidTablet = function () { return /android/.test(x) && !/mobile/.test(x) }; n.androidTablet.api = ["not"]; n.blackberry = function () { return /blackberry/.test(x) || /bb10/.test(x) }; n.blackberry.api = ["not"]; n.chrome = function (n) { var t = /google inc/.test(A) ? x.match(/(?:chrome|crios)\/(\d+)/) : null; return t !== null && f(t[1], n) }; n.chrome.api = ["not"]; n.desktop = function () { return n.not.mobile() && n.not.tablet() }; n.desktop.api = ["not"]; n.edge = function (n) { var t = x.match(/edge\/(\d+)/); return t !== null && f(t[1], n) }; n.edge.api = ["not"]; n.firefox = function (n) { var t = x.match(/(?:firefox|fxios)\/(\d+)/); return t !== null && f(t[1], n) }; n.firefox.api = ["not"]; n.ie = function (n) { var t = x.match(/(?:msie |trident.+?; rv:)(\d+)/); return t !== null && f(t[1], n) }; n.ie.api = ["not"]; n.ios = function () { return n.iphone() || n.ipad() || n.ipod() }; n.ios.api = ["not"]; n.ipad = function (n) { var t = x.match(/ipad.+?os (\d+)/); return t !== null && f(t[1], n) }; n.ipad.api = ["not"]; n.iphone = function (n) { var t = x.match(/iphone(?:.+?os (\d+))?/); return t !== null && f(t[1] || 1, n) }; n.iphone.api = ["not"]; n.ipod = function (n) { var t = x.match(/ipod.+?os (\d+)/); return t !== null && f(t[1], n) }; n.ipod.api = ["not"]; n.linux = function () { return /linux/.test(y) }; n.linux.api = ["not"]; n.mac = function () { return /mac/.test(y) }; n.mac.api = ["not"]; n.mobile = function () { return n.iphone() || n.ipod() || n.androidPhone() || n.blackberry() || n.windowsPhone() }; n.mobile.api = ["not"]; n.offline = a(n.online); n.offline.api = ["not"]; n.online = function () { return !w || w.onLine === true }; n.online.api = ["not"]; n.opera = function (n) { var t = x.match(/(?:^opera.+?version|opr)\/(\d+)/); return t !== null && f(t[1], n) }; n.opera.api = ["not"]; n.phantom = function (n) { var t = x.match(/phantomjs\/(\d+)/); return t !== null && f(t[1], n) }; n.phantom.api = ["not"]; n.safari = function (n) { var t = x.match(/version\/(\d+).+?safari/); return t !== null && f(t[1], n) }; n.safari.api = ["not"]; n.tablet = function () { return n.ipad() || n.androidTablet() || n.windowsTablet() }; n.tablet.api = ["not"]; n.touchDevice = function () { return !!g && ("ontouchstart" in h || "DocumentTouch" in h && g instanceof DocumentTouch) }; n.touchDevice.api = ["not"]; n.windows = function () { return /win/.test(y) }; n.windows.api = ["not"]; n.windowsPhone = function () { return n.windows() && /phone/.test(x) }; n.windowsPhone.api = ["not"]; n.windowsTablet = function () { return n.windows() && n.not.windowsPhone() && /touch/.test(x) }; n.windowsTablet.api = ["not"]; n.propertyCount = function (t, e) { if (n.not.object(t) || n.not.number(e)) { return false } var a = 0; for (var u in t) { if (r.call(t, u) && ++a > e) { return false } } return a === e }; n.propertyCount.api = ["not"]; n.propertyDefined = function (t, e) { return n.object(t) && n.string(e) && e in t }; n.propertyDefined.api = ["not"]; n.inArray = function (t, e) { if (n.not.array(e)) { return false } for (var r = 0; r < e.length; r++) { if (e[r] === t) { return true } } return false }; n.inArray.api = ["not"]; n.sorted = function (t, e) { if (n.not.array(t)) { return false } var r = i[e] || i[">="]; for (var a = 1; a < t.length; a++) { if (!r(t[a], t[a - 1])) { return false } } return true }; function j() { var t = n; for (var e in t) { if (r.call(t, e) && n["function"](t[e])) { var i = t[e].api || ["not", "all", "any"]; for (var f = 0; f < i.length; f++) { if (i[f] === "not") { n.not[e] = a(n[e]) } if (i[f] === "all") { n.all[e] = u(n[e]) } if (i[f] === "any") { n.any[e] = o(n[e]) } } } } } j(); n.setNamespace = function () { b.is = m; return this }; n.setRegexp = function (n, t) { for (var e in l) { if (r.call(l, e) && t === e) { l[e] = n } } }; return n });
\ No newline at end of file
diff --git a/server/libs/requestIp.js b/server/libs/requestIp.js
new file mode 100644
index 00000000..93dbc5e1
--- /dev/null
+++ b/server/libs/requestIp.js
@@ -0,0 +1,174 @@
+// SOURCE: https://github.com/pbojinov/request-ip
+
+"use strict";
+
+function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
+
+var is = require('./isJs');
+/**
+ * Parse x-forwarded-for headers.
+ *
+ * @param {string} value - The value to be parsed.
+ * @return {string|null} First known IP address, if any.
+ */
+
+
+function getClientIpFromXForwardedFor(value) {
+ if (!is.existy(value)) {
+ return null;
+ }
+
+ if (is.not.string(value)) {
+ throw new TypeError("Expected a string, got \"".concat(_typeof(value), "\""));
+ } // x-forwarded-for may return multiple IP addresses in the format:
+ // "client IP, proxy 1 IP, proxy 2 IP"
+ // Therefore, the right-most IP address is the IP address of the most recent proxy
+ // and the left-most IP address is the IP address of the originating client.
+ // source: http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html
+ // Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP)
+
+
+ var forwardedIps = value.split(',').map(function (e) {
+ var ip = e.trim();
+
+ if (ip.includes(':')) {
+ var splitted = ip.split(':'); // make sure we only use this if it's ipv4 (ip:port)
+
+ if (splitted.length === 2) {
+ return splitted[0];
+ }
+ }
+
+ return ip;
+ }); // Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650).
+ // Therefore taking the left-most IP address that is not unknown
+ // A Squid configuration directive can also set the value to "unknown" (http://www.squid-cache.org/Doc/config/forwarded_for/)
+
+ return forwardedIps.find(is.ip);
+}
+/**
+ * Determine client IP address.
+ *
+ * @param req
+ * @returns {string} ip - The IP address if known, defaulting to empty string if unknown.
+ */
+
+
+function getClientIp(req) {
+ // Server is probably behind a proxy.
+ if (req.headers) {
+ // Standard headers used by Amazon EC2, Heroku, and others.
+ if (is.ip(req.headers['x-client-ip'])) {
+ return req.headers['x-client-ip'];
+ } // Load-balancers (AWS ELB) or proxies.
+
+
+ var xForwardedFor = getClientIpFromXForwardedFor(req.headers['x-forwarded-for']);
+
+ if (is.ip(xForwardedFor)) {
+ return xForwardedFor;
+ } // Cloudflare.
+ // @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
+ // CF-Connecting-IP - applied to every request to the origin.
+
+
+ if (is.ip(req.headers['cf-connecting-ip'])) {
+ return req.headers['cf-connecting-ip'];
+ } // Fastly and Firebase hosting header (When forwared to cloud function)
+
+
+ if (is.ip(req.headers['fastly-client-ip'])) {
+ return req.headers['fastly-client-ip'];
+ } // Akamai and Cloudflare: True-Client-IP.
+
+
+ if (is.ip(req.headers['true-client-ip'])) {
+ return req.headers['true-client-ip'];
+ } // Default nginx proxy/fcgi; alternative to x-forwarded-for, used by some proxies.
+
+
+ if (is.ip(req.headers['x-real-ip'])) {
+ return req.headers['x-real-ip'];
+ } // (Rackspace LB and Riverbed's Stingray)
+ // http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address
+ // https://splash.riverbed.com/docs/DOC-1926
+
+
+ if (is.ip(req.headers['x-cluster-client-ip'])) {
+ return req.headers['x-cluster-client-ip'];
+ }
+
+ if (is.ip(req.headers['x-forwarded'])) {
+ return req.headers['x-forwarded'];
+ }
+
+ if (is.ip(req.headers['forwarded-for'])) {
+ return req.headers['forwarded-for'];
+ }
+
+ if (is.ip(req.headers.forwarded)) {
+ return req.headers.forwarded;
+ }
+ } // Remote address checks.
+
+
+ if (is.existy(req.connection)) {
+ if (is.ip(req.connection.remoteAddress)) {
+ return req.connection.remoteAddress;
+ }
+
+ if (is.existy(req.connection.socket) && is.ip(req.connection.socket.remoteAddress)) {
+ return req.connection.socket.remoteAddress;
+ }
+ }
+
+ if (is.existy(req.socket) && is.ip(req.socket.remoteAddress)) {
+ return req.socket.remoteAddress;
+ }
+
+ if (is.existy(req.info) && is.ip(req.info.remoteAddress)) {
+ return req.info.remoteAddress;
+ } // AWS Api Gateway + Lambda
+
+
+ if (is.existy(req.requestContext) && is.existy(req.requestContext.identity) && is.ip(req.requestContext.identity.sourceIp)) {
+ return req.requestContext.identity.sourceIp;
+ }
+
+ return null;
+}
+/**
+ * Expose request IP as a middleware.
+ *
+ * @param {object} [options] - Configuration.
+ * @param {string} [options.attributeName] - Name of attribute to augment request object with.
+ * @return {*}
+ */
+
+
+function mw(options) {
+ // Defaults.
+ var configuration = is.not.existy(options) ? {} : options; // Validation.
+
+ if (is.not.object(configuration)) {
+ throw new TypeError('Options must be an object!');
+ }
+
+ var attributeName = configuration.attributeName || 'clientIp';
+ return function (req, res, next) {
+ var ip = getClientIp(req);
+ Object.defineProperty(req, attributeName, {
+ get: function get() {
+ return ip;
+ },
+ configurable: true
+ });
+ next();
+ };
+}
+
+module.exports = {
+ getClientIpFromXForwardedFor: getClientIpFromXForwardedFor,
+ getClientIp: getClientIp,
+ mw: mw
+};
diff --git a/server/libs/sanitizeHtml.js b/server/libs/sanitizeHtml.js
new file mode 100644
index 00000000..3fee985e
--- /dev/null
+++ b/server/libs/sanitizeHtml.js
@@ -0,0 +1,874 @@
+/*
+ sanitize-html (Apostrophe Technologies)
+ SOURCE: https://github.com/apostrophecms/sanitize-html
+ LICENSE: https://github.com/apostrophecms/sanitize-html/blob/main/LICENSE
+
+ Modified for audiobookshelf
+*/
+
+const htmlparser = require('htmlparser2');
+// const escapeStringRegexp = require('escape-string-regexp');
+// const { isPlainObject } = require('is-plain-object');
+// const deepmerge = require('deepmerge');
+// const parseSrcset = require('parse-srcset');
+// const { parse: postcssParse } = require('postcss');
+// Tags that can conceivably represent stand-alone media.
+
+// ABS UPDATE: Packages not necessary
+// SOURCE: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
+function escapeStringRegexp(string) {
+ if (typeof string !== 'string') {
+ throw new TypeError('Expected a string');
+ }
+
+ // Escape characters with special meaning either inside or outside character sets.
+ // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
+ return string
+ .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
+ .replace(/-/g, '\\x2d');
+}
+
+// SOURCE: https://github.com/jonschlinkert/is-plain-object/blob/master/is-plain-object.js
+function isObject(o) {
+ return Object.prototype.toString.call(o) === '[object Object]';
+}
+
+function isPlainObject(o) {
+ var ctor, prot;
+
+ if (isObject(o) === false) return false;
+
+ // If has modified constructor
+ ctor = o.constructor;
+ if (ctor === undefined) return true;
+
+ // If has modified prototype
+ prot = ctor.prototype;
+ if (isObject(prot) === false) return false;
+
+ // If constructor does not have an Object-specific method
+ if (prot.hasOwnProperty('isPrototypeOf') === false) {
+ return false;
+ }
+
+ // Most likely a plain Object
+ return true;
+};
+
+
+const mediaTags = [
+ 'img', 'audio', 'video', 'picture', 'svg',
+ 'object', 'map', 'iframe', 'embed'
+];
+// Tags that are inherently vulnerable to being used in XSS attacks.
+const vulnerableTags = ['script', 'style'];
+
+function each(obj, cb) {
+ if (obj) {
+ Object.keys(obj).forEach(function (key) {
+ cb(obj[key], key);
+ });
+ }
+}
+
+// Avoid false positives with .__proto__, .hasOwnProperty, etc.
+function has(obj, key) {
+ return ({}).hasOwnProperty.call(obj, key);
+}
+
+// Returns those elements of `a` for which `cb(a)` returns truthy
+function filter(a, cb) {
+ const n = [];
+ each(a, function (v) {
+ if (cb(v)) {
+ n.push(v);
+ }
+ });
+ return n;
+}
+
+function isEmptyObject(obj) {
+ for (const key in obj) {
+ if (has(obj, key)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function stringifySrcset(parsedSrcset) {
+ return parsedSrcset.map(function (part) {
+ if (!part.url) {
+ throw new Error('URL missing');
+ }
+
+ return (
+ part.url +
+ (part.w ? ` ${part.w}w` : '') +
+ (part.h ? ` ${part.h}h` : '') +
+ (part.d ? ` ${part.d}x` : '')
+ );
+ }).join(', ');
+}
+
+module.exports = sanitizeHtml;
+
+// A valid attribute name.
+// We use a tolerant definition based on the set of strings defined by
+// html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state
+// and html.spec.whatwg.org/multipage/parsing.html#attribute-name-state .
+// The characters accepted are ones which can be appended to the attribute
+// name buffer without triggering a parse error:
+// * unexpected-equals-sign-before-attribute-name
+// * unexpected-null-character
+// * unexpected-character-in-attribute-name
+// We exclude the empty string because it's impossible to get to the after
+// attribute name state with an empty attribute name buffer.
+const VALID_HTML_ATTRIBUTE_NAME = /^[^\0\t\n\f\r /<=>]+$/;
+
+// Ignore the _recursing flag; it's there for recursive
+// invocation as a guard against this exploit:
+// https://github.com/fb55/htmlparser2/issues/105
+
+function sanitizeHtml(html, options, _recursing) {
+ if (html == null) {
+ return '';
+ }
+
+ let result = '';
+ // Used for hot swapping the result variable with an empty string in order to "capture" the text written to it.
+ let tempResult = '';
+
+ function Frame(tag, attribs) {
+ const that = this;
+ this.tag = tag;
+ this.attribs = attribs || {};
+ this.tagPosition = result.length;
+ this.text = ''; // Node inner text
+ this.mediaChildren = [];
+
+ this.updateParentNodeText = function () {
+ if (stack.length) {
+ const parentFrame = stack[stack.length - 1];
+ parentFrame.text += that.text;
+ }
+ };
+
+ this.updateParentNodeMediaChildren = function () {
+ if (stack.length && mediaTags.includes(this.tag)) {
+ const parentFrame = stack[stack.length - 1];
+ parentFrame.mediaChildren.push(this.tag);
+ }
+ };
+ }
+
+ options = Object.assign({}, sanitizeHtml.defaults, options);
+ options.parser = Object.assign({}, htmlParserDefaults, options.parser);
+
+ // vulnerableTags
+ vulnerableTags.forEach(function (tag) {
+ if (
+ options.allowedTags && options.allowedTags.indexOf(tag) > -1 &&
+ !options.allowVulnerableTags
+ ) {
+ console.warn(`\n\n⚠️ Your \`allowedTags\` option includes, \`${tag}\`, which is inherently\nvulnerable to XSS attacks. Please remove it from \`allowedTags\`.\nOr, to disable this warning, add the \`allowVulnerableTags\` option\nand ensure you are accounting for this risk.\n\n`);
+ }
+ });
+
+ // Tags that contain something other than HTML, or where discarding
+ // the text when the tag is disallowed makes sense for other reasons.
+ // If we are not allowing these tags, we should drop their content too.
+ // For other tags you would drop the tag but keep its content.
+ const nonTextTagsArray = options.nonTextTags || [
+ 'script',
+ 'style',
+ 'textarea',
+ 'option'
+ ];
+ let allowedAttributesMap;
+ let allowedAttributesGlobMap;
+ if (options.allowedAttributes) {
+ allowedAttributesMap = {};
+ allowedAttributesGlobMap = {};
+ each(options.allowedAttributes, function (attributes, tag) {
+ allowedAttributesMap[tag] = [];
+ const globRegex = [];
+ attributes.forEach(function (obj) {
+ if (typeof obj === 'string' && obj.indexOf('*') >= 0) {
+ globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*'));
+ } else {
+ allowedAttributesMap[tag].push(obj);
+ }
+ });
+ if (globRegex.length) {
+ allowedAttributesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');
+ }
+ });
+ }
+ const allowedClassesMap = {};
+ const allowedClassesGlobMap = {};
+ const allowedClassesRegexMap = {};
+ each(options.allowedClasses, function (classes, tag) {
+ // Implicitly allows the class attribute
+ if (allowedAttributesMap) {
+ if (!has(allowedAttributesMap, tag)) {
+ allowedAttributesMap[tag] = [];
+ }
+ allowedAttributesMap[tag].push('class');
+ }
+
+ allowedClassesMap[tag] = [];
+ allowedClassesRegexMap[tag] = [];
+ const globRegex = [];
+ classes.forEach(function (obj) {
+ if (typeof obj === 'string' && obj.indexOf('*') >= 0) {
+ globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*'));
+ } else if (obj instanceof RegExp) {
+ allowedClassesRegexMap[tag].push(obj);
+ } else {
+ allowedClassesMap[tag].push(obj);
+ }
+ });
+ if (globRegex.length) {
+ allowedClassesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');
+ }
+ });
+
+ const transformTagsMap = {};
+ let transformTagsAll;
+ each(options.transformTags, function (transform, tag) {
+ let transFun;
+ if (typeof transform === 'function') {
+ transFun = transform;
+ } else if (typeof transform === 'string') {
+ transFun = sanitizeHtml.simpleTransform(transform);
+ }
+ if (tag === '*') {
+ transformTagsAll = transFun;
+ } else {
+ transformTagsMap[tag] = transFun;
+ }
+ });
+
+ let depth;
+ let stack;
+ let skipMap;
+ let transformMap;
+ let skipText;
+ let skipTextDepth;
+ let addedText = false;
+
+ initializeState();
+
+ const parser = new htmlparser.Parser({
+ onopentag: function (name, attribs) {
+ // If `enforceHtmlBoundary` is `true` and this has found the opening
+ // `html` tag, reset the state.
+ if (options.enforceHtmlBoundary && name === 'html') {
+ initializeState();
+ }
+
+ if (skipText) {
+ skipTextDepth++;
+ return;
+ }
+ const frame = new Frame(name, attribs);
+ stack.push(frame);
+
+ let skip = false;
+ const hasText = !!frame.text;
+ let transformedTag;
+ if (has(transformTagsMap, name)) {
+ transformedTag = transformTagsMap[name](name, attribs);
+
+ frame.attribs = attribs = transformedTag.attribs;
+
+ if (transformedTag.text !== undefined) {
+ frame.innerText = transformedTag.text;
+ }
+
+ if (name !== transformedTag.tagName) {
+ frame.name = name = transformedTag.tagName;
+ transformMap[depth] = transformedTag.tagName;
+ }
+ }
+ if (transformTagsAll) {
+ transformedTag = transformTagsAll(name, attribs);
+
+ frame.attribs = attribs = transformedTag.attribs;
+ if (name !== transformedTag.tagName) {
+ frame.name = name = transformedTag.tagName;
+ transformMap[depth] = transformedTag.tagName;
+ }
+ }
+
+ if ((options.allowedTags && options.allowedTags.indexOf(name) === -1) || (options.disallowedTagsMode === 'recursiveEscape' && !isEmptyObject(skipMap)) || (options.nestingLimit != null && depth >= options.nestingLimit)) {
+ skip = true;
+ skipMap[depth] = true;
+ if (options.disallowedTagsMode === 'discard') {
+ if (nonTextTagsArray.indexOf(name) !== -1) {
+ skipText = true;
+ skipTextDepth = 1;
+ }
+ }
+ skipMap[depth] = true;
+ }
+ depth++;
+ if (skip) {
+ if (options.disallowedTagsMode === 'discard') {
+ // We want the contents but not this tag
+ return;
+ }
+ tempResult = result;
+ result = '';
+ }
+ result += '<' + name;
+
+ if (name === 'script') {
+ if (options.allowedScriptHostnames || options.allowedScriptDomains) {
+ frame.innerText = '';
+ }
+ }
+
+ if (!allowedAttributesMap || has(allowedAttributesMap, name) || allowedAttributesMap['*']) {
+ each(attribs, function (value, a) {
+ if (!VALID_HTML_ATTRIBUTE_NAME.test(a)) {
+ // This prevents part of an attribute name in the output from being
+ // interpreted as the end of an attribute, or end of a tag.
+ delete frame.attribs[a];
+ return;
+ }
+ let parsed;
+ // check allowedAttributesMap for the element and attribute and modify the value
+ // as necessary if there are specific values defined.
+ let passedAllowedAttributesMapCheck = false;
+ if (!allowedAttributesMap ||
+ (has(allowedAttributesMap, name) && allowedAttributesMap[name].indexOf(a) !== -1) ||
+ (allowedAttributesMap['*'] && allowedAttributesMap['*'].indexOf(a) !== -1) ||
+ (has(allowedAttributesGlobMap, name) && allowedAttributesGlobMap[name].test(a)) ||
+ (allowedAttributesGlobMap['*'] && allowedAttributesGlobMap['*'].test(a))) {
+ passedAllowedAttributesMapCheck = true;
+ } else if (allowedAttributesMap && allowedAttributesMap[name]) {
+ for (const o of allowedAttributesMap[name]) {
+ if (isPlainObject(o) && o.name && (o.name === a)) {
+ passedAllowedAttributesMapCheck = true;
+ let newValue = '';
+ if (o.multiple === true) {
+ // verify the values that are allowed
+ const splitStrArray = value.split(' ');
+ for (const s of splitStrArray) {
+ if (o.values.indexOf(s) !== -1) {
+ if (newValue === '') {
+ newValue = s;
+ } else {
+ newValue += ' ' + s;
+ }
+ }
+ }
+ } else if (o.values.indexOf(value) >= 0) {
+ // verified an allowed value matches the entire attribute value
+ newValue = value;
+ }
+ value = newValue;
+ }
+ }
+ }
+ if (passedAllowedAttributesMapCheck) {
+ if (options.allowedSchemesAppliedToAttributes.indexOf(a) !== -1) {
+ if (naughtyHref(name, value)) {
+ delete frame.attribs[a];
+ return;
+ }
+ }
+
+ if (name === 'script' && a === 'src') {
+
+ let allowed = true;
+
+ try {
+ const parsed = new URL(value);
+
+ if (options.allowedScriptHostnames || options.allowedScriptDomains) {
+ const allowedHostname = (options.allowedScriptHostnames || []).find(function (hostname) {
+ return hostname === parsed.hostname;
+ });
+ const allowedDomain = (options.allowedScriptDomains || []).find(function (domain) {
+ return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
+ });
+ allowed = allowedHostname || allowedDomain;
+ }
+ } catch (e) {
+ allowed = false;
+ }
+
+ if (!allowed) {
+ delete frame.attribs[a];
+ return;
+ }
+ }
+
+ if (name === 'iframe' && a === 'src') {
+ let allowed = true;
+ try {
+ // Chrome accepts \ as a substitute for / in the // at the
+ // start of a URL, so rewrite accordingly to prevent exploit.
+ // Also drop any whitespace at that point in the URL
+ value = value.replace(/^(\w+:)?\s*[\\/]\s*[\\/]/, '$1//');
+ if (value.startsWith('relative:')) {
+ // An attempt to exploit our workaround for base URLs being
+ // mandatory for relative URL validation in the WHATWG
+ // URL parser, reject it
+ throw new Error('relative: exploit attempt');
+ }
+ // naughtyHref is in charge of whether protocol relative URLs
+ // are cool. Here we are concerned just with allowed hostnames and
+ // whether to allow relative URLs.
+ //
+ // Build a placeholder "base URL" against which any reasonable
+ // relative URL may be parsed successfully
+ let base = 'relative://relative-site';
+ for (let i = 0; (i < 100); i++) {
+ base += `/${i}`;
+ }
+ const parsed = new URL(value, base);
+ const isRelativeUrl = parsed && parsed.hostname === 'relative-site' && parsed.protocol === 'relative:';
+ if (isRelativeUrl) {
+ // default value of allowIframeRelativeUrls is true
+ // unless allowedIframeHostnames or allowedIframeDomains specified
+ allowed = has(options, 'allowIframeRelativeUrls')
+ ? options.allowIframeRelativeUrls
+ : (!options.allowedIframeHostnames && !options.allowedIframeDomains);
+ } else if (options.allowedIframeHostnames || options.allowedIframeDomains) {
+ const allowedHostname = (options.allowedIframeHostnames || []).find(function (hostname) {
+ return hostname === parsed.hostname;
+ });
+ const allowedDomain = (options.allowedIframeDomains || []).find(function (domain) {
+ return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
+ });
+ allowed = allowedHostname || allowedDomain;
+ }
+ } catch (e) {
+ // Unparseable iframe src
+ allowed = false;
+ }
+ if (!allowed) {
+ delete frame.attribs[a];
+ return;
+ }
+ }
+ if (a === 'srcset') {
+ delete frame.attribs[a];
+
+ // ABS UPDATE: srcset not necessary
+ // try {
+ // parsed = parseSrcset(value);
+ // parsed.forEach(function (value) {
+ // if (naughtyHref('srcset', value.url)) {
+ // value.evil = true;
+ // }
+ // });
+ // parsed = filter(parsed, function (v) {
+ // return !v.evil;
+ // });
+ // if (!parsed.length) {
+ // delete frame.attribs[a];
+ // return;
+ // } else {
+ // value = stringifySrcset(filter(parsed, function (v) {
+ // return !v.evil;
+ // }));
+ // frame.attribs[a] = value;
+ // }
+ // } catch (e) {
+ // // Unparseable srcset
+ // delete frame.attribs[a];
+ // return;
+ // }
+ }
+ if (a === 'class') {
+ const allowedSpecificClasses = allowedClassesMap[name];
+ const allowedWildcardClasses = allowedClassesMap['*'];
+ const allowedSpecificClassesGlob = allowedClassesGlobMap[name];
+ const allowedSpecificClassesRegex = allowedClassesRegexMap[name];
+ const allowedWildcardClassesGlob = allowedClassesGlobMap['*'];
+ const allowedClassesGlobs = [
+ allowedSpecificClassesGlob,
+ allowedWildcardClassesGlob
+ ]
+ .concat(allowedSpecificClassesRegex)
+ .filter(function (t) {
+ return t;
+ });
+ if (allowedSpecificClasses && allowedWildcardClasses) {
+ // ABS UPDATE: classes and wildcard classes not necessary now
+ // value = filterClasses(value, deepmerge(allowedSpecificClasses, allowedWildcardClasses), allowedClassesGlobs);
+ } else {
+ value = filterClasses(value, allowedSpecificClasses || allowedWildcardClasses, allowedClassesGlobs);
+ }
+ if (!value.length) {
+ delete frame.attribs[a];
+ return;
+ }
+ }
+ if (a === 'style') {
+ delete frame.attribs[a];
+
+ // ABS UPDATE: Styles not necessary
+ // try {
+ // const abstractSyntaxTree = postcssParse(name + ' {' + value + '}');
+ // const filteredAST = filterCss(abstractSyntaxTree, options.allowedStyles);
+
+ // value = stringifyStyleAttributes(filteredAST);
+
+ // if (value.length === 0) {
+ // delete frame.attribs[a];
+ // return;
+ // }
+ // } catch (e) {
+ // delete frame.attribs[a];
+ // return;
+ // }
+ }
+ result += ' ' + a;
+ if (value && value.length) {
+ result += '="' + escapeHtml(value, true) + '"';
+ }
+ } else {
+ delete frame.attribs[a];
+ }
+ });
+ }
+ if (options.selfClosing.indexOf(name) !== -1) {
+ result += ' />';
+ } else {
+ result += '>';
+ if (frame.innerText && !hasText && !options.textFilter) {
+ result += escapeHtml(frame.innerText);
+ addedText = true;
+ }
+ }
+ if (skip) {
+ result = tempResult + escapeHtml(result);
+ tempResult = '';
+ }
+ },
+ ontext: function (text) {
+ if (skipText) {
+ return;
+ }
+ const lastFrame = stack[stack.length - 1];
+ let tag;
+
+ if (lastFrame) {
+ tag = lastFrame.tag;
+ // If inner text was set by transform function then let's use it
+ text = lastFrame.innerText !== undefined ? lastFrame.innerText : text;
+ }
+
+ if (options.disallowedTagsMode === 'discard' && ((tag === 'script') || (tag === 'style'))) {
+ // htmlparser2 gives us these as-is. Escaping them ruins the content. Allowing
+ // script tags is, by definition, game over for XSS protection, so if that's
+ // your concern, don't allow them. The same is essentially true for style tags
+ // which have their own collection of XSS vectors.
+ result += text;
+ } else {
+ const escaped = escapeHtml(text, false);
+ if (options.textFilter && !addedText) {
+ result += options.textFilter(escaped, tag);
+ } else if (!addedText) {
+ result += escaped;
+ }
+ }
+ if (stack.length) {
+ const frame = stack[stack.length - 1];
+ frame.text += text;
+ }
+ },
+ onclosetag: function (name) {
+
+ if (skipText) {
+ skipTextDepth--;
+ if (!skipTextDepth) {
+ skipText = false;
+ } else {
+ return;
+ }
+ }
+
+ const frame = stack.pop();
+ if (!frame) {
+ // Do not crash on bad markup
+ return;
+ }
+ skipText = options.enforceHtmlBoundary ? name === 'html' : false;
+ depth--;
+ const skip = skipMap[depth];
+ if (skip) {
+ delete skipMap[depth];
+ if (options.disallowedTagsMode === 'discard') {
+ frame.updateParentNodeText();
+ return;
+ }
+ tempResult = result;
+ result = '';
+ }
+
+ if (transformMap[depth]) {
+ name = transformMap[depth];
+ delete transformMap[depth];
+ }
+
+ if (options.exclusiveFilter && options.exclusiveFilter(frame)) {
+ result = result.substr(0, frame.tagPosition);
+ return;
+ }
+
+ frame.updateParentNodeMediaChildren();
+ frame.updateParentNodeText();
+
+ if (options.selfClosing.indexOf(name) !== -1) {
+ // Already output />
+ if (skip) {
+ result = tempResult;
+ tempResult = '';
+ }
+ return;
+ }
+
+ result += '' + name + '>';
+ if (skip) {
+ result = tempResult + escapeHtml(result);
+ tempResult = '';
+ }
+ addedText = false;
+ }
+ }, options.parser);
+ parser.write(html);
+ parser.end();
+
+ return result;
+
+ function initializeState() {
+ result = '';
+ depth = 0;
+ stack = [];
+ skipMap = {};
+ transformMap = {};
+ skipText = false;
+ skipTextDepth = 0;
+ }
+
+ function escapeHtml(s, quote) {
+ if (typeof (s) !== 'string') {
+ s = s + '';
+ }
+ if (options.parser.decodeEntities) {
+ s = s.replace(/&/g, '&').replace(//g, '>');
+ if (quote) {
+ s = s.replace(/"/g, '"');
+ }
+ }
+ // TODO: this is inadequate because it will pass `&0;`. This approach
+ // will not work, each & must be considered with regard to whether it
+ // is followed by a 100% syntactically valid entity or not, and escaped
+ // if it is not. If this bothers you, don't set parser.decodeEntities
+ // to false. (The default is true.)
+ s = s.replace(/&(?![a-zA-Z0-9#]{1,20};)/g, '&') // Match ampersands not part of existing HTML entity
+ .replace(//g, '>');
+ if (quote) {
+ s = s.replace(/"/g, '"');
+ }
+ return s;
+ }
+
+ function naughtyHref(name, href) {
+ // Browsers ignore character codes of 32 (space) and below in a surprising
+ // number of situations. Start reading here:
+ // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab
+ // eslint-disable-next-line no-control-regex
+ href = href.replace(/[\x00-\x20]+/g, '');
+ // Clobber any comments in URLs, which the browser might
+ // interpret inside an XML data island, allowing
+ // a javascript: URL to be snuck through
+ href = href.replace(//g, '');
+ // Case insensitive so we don't get faked out by JAVASCRIPT #1
+ // Allow more characters after the first so we don't get faked
+ // out by certain schemes browsers accept
+ const matches = href.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/);
+ if (!matches) {
+ // Protocol-relative URL starting with any combination of '/' and '\'
+ if (href.match(/^[/\\]{2}/)) {
+ return !options.allowProtocolRelative;
+ }
+
+ // No scheme
+ return false;
+ }
+ const scheme = matches[1].toLowerCase();
+
+ if (has(options.allowedSchemesByTag, name)) {
+ return options.allowedSchemesByTag[name].indexOf(scheme) === -1;
+ }
+
+ return !options.allowedSchemes || options.allowedSchemes.indexOf(scheme) === -1;
+ }
+
+ /**
+ * Filters user input css properties by allowlisted regex attributes.
+ * Modifies the abstractSyntaxTree object.
+ *
+ * @param {object} abstractSyntaxTree - Object representation of CSS attributes.
+ * @property {array[Declaration]} abstractSyntaxTree.nodes[0] - Each object cointains prop and value key, i.e { prop: 'color', value: 'red' }.
+ * @param {object} allowedStyles - Keys are properties (i.e color), value is list of permitted regex rules (i.e /green/i).
+ * @return {object} - The modified tree.
+ */
+ // function filterCss(abstractSyntaxTree, allowedStyles) {
+ // if (!allowedStyles) {
+ // return abstractSyntaxTree;
+ // }
+
+ // const astRules = abstractSyntaxTree.nodes[0];
+ // let selectedRule;
+
+ // // Merge global and tag-specific styles into new AST.
+ // if (allowedStyles[astRules.selector] && allowedStyles['*']) {
+ // selectedRule = deepmerge(
+ // allowedStyles[astRules.selector],
+ // allowedStyles['*']
+ // );
+ // } else {
+ // selectedRule = allowedStyles[astRules.selector] || allowedStyles['*'];
+ // }
+
+ // if (selectedRule) {
+ // abstractSyntaxTree.nodes[0].nodes = astRules.nodes.reduce(filterDeclarations(selectedRule), []);
+ // }
+
+ // return abstractSyntaxTree;
+ // }
+
+ /**
+ * Extracts the style attributes from an AbstractSyntaxTree and formats those
+ * values in the inline style attribute format.
+ *
+ * @param {AbstractSyntaxTree} filteredAST
+ * @return {string} - Example: "color:yellow;text-align:center !important;font-family:helvetica;"
+ */
+ function stringifyStyleAttributes(filteredAST) {
+ return filteredAST.nodes[0].nodes
+ .reduce(function (extractedAttributes, attrObject) {
+ extractedAttributes.push(
+ `${attrObject.prop}:${attrObject.value}${attrObject.important ? ' !important' : ''}`
+ );
+ return extractedAttributes;
+ }, [])
+ .join(';');
+ }
+
+ /**
+ * Filters the existing attributes for the given property. Discards any attributes
+ * which don't match the allowlist.
+ *
+ * @param {object} selectedRule - Example: { color: red, font-family: helvetica }
+ * @param {array} allowedDeclarationsList - List of declarations which pass the allowlist.
+ * @param {object} attributeObject - Object representing the current css property.
+ * @property {string} attributeObject.type - Typically 'declaration'.
+ * @property {string} attributeObject.prop - The CSS property, i.e 'color'.
+ * @property {string} attributeObject.value - The corresponding value to the css property, i.e 'red'.
+ * @return {function} - When used in Array.reduce, will return an array of Declaration objects
+ */
+ function filterDeclarations(selectedRule) {
+ return function (allowedDeclarationsList, attributeObject) {
+ // If this property is allowlisted...
+ if (has(selectedRule, attributeObject.prop)) {
+ const matchesRegex = selectedRule[attributeObject.prop].some(function (regularExpression) {
+ return regularExpression.test(attributeObject.value);
+ });
+
+ if (matchesRegex) {
+ allowedDeclarationsList.push(attributeObject);
+ }
+ }
+ return allowedDeclarationsList;
+ };
+ }
+
+ function filterClasses(classes, allowed, allowedGlobs) {
+ if (!allowed) {
+ // The class attribute is allowed without filtering on this tag
+ return classes;
+ }
+ classes = classes.split(/\s+/);
+ return classes.filter(function (clss) {
+ return allowed.indexOf(clss) !== -1 || allowedGlobs.some(function (glob) {
+ return glob.test(clss);
+ });
+ }).join(' ');
+ }
+}
+
+// Defaults are accessible to you so that you can use them as a starting point
+// programmatically if you wish
+
+const htmlParserDefaults = {
+ decodeEntities: true
+};
+sanitizeHtml.defaults = {
+ allowedTags: [
+ // Sections derived from MDN element categories and limited to the more
+ // benign categories.
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Element
+ // Content sectioning
+ 'address', 'article', 'aside', 'footer', 'header',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
+ 'main', 'nav', 'section',
+ // Text content
+ 'blockquote', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure',
+ 'hr', 'li', 'main', 'ol', 'p', 'pre', 'ul',
+ // Inline text semantics
+ 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn',
+ 'em', 'i', 'kbd', 'mark', 'q',
+ 'rb', 'rp', 'rt', 'rtc', 'ruby',
+ 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',
+ // Table content
+ 'caption', 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot', 'th',
+ 'thead', 'tr'
+ ],
+ disallowedTagsMode: 'discard',
+ allowedAttributes: {
+ a: ['href', 'name', 'target'],
+ // We don't currently allow img itself by default, but
+ // these attributes would make sense if we did.
+ img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading']
+ },
+ // Lots of these won't come up by default because we don't allow them
+ selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
+ // URL schemes we permit
+ allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'],
+ allowedSchemesByTag: {},
+ allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
+ allowProtocolRelative: true,
+ enforceHtmlBoundary: false
+};
+
+sanitizeHtml.simpleTransform = function (newTagName, newAttribs, merge) {
+ merge = (merge === undefined) ? true : merge;
+ newAttribs = newAttribs || {};
+
+ return function (tagName, attribs) {
+ let attrib;
+ if (merge) {
+ for (attrib in newAttribs) {
+ attribs[attrib] = newAttribs[attrib];
+ }
+ } else {
+ attribs = newAttribs;
+ }
+
+ return {
+ tagName: newTagName,
+ attribs: attribs
+ };
+ };
+};
\ No newline at end of file
diff --git a/server/libs/uaParserJs.js b/server/libs/uaParserJs.js
new file mode 100644
index 00000000..da3a75b4
--- /dev/null
+++ b/server/libs/uaParserJs.js
@@ -0,0 +1,4 @@
+/* UAParser.js v0.7.31
+ Copyright © 2012-2021 Faisal Salman
+ MIT License */
+(function (window, undefined) { "use strict"; var LIBVERSION = "0.7.31", EMPTY = "", UNKNOWN = "?", FUNC_TYPE = "function", UNDEF_TYPE = "undefined", OBJ_TYPE = "object", STR_TYPE = "string", MAJOR = "major", MODEL = "model", NAME = "name", TYPE = "type", VENDOR = "vendor", VERSION = "version", ARCHITECTURE = "architecture", CONSOLE = "console", MOBILE = "mobile", TABLET = "tablet", SMARTTV = "smarttv", WEARABLE = "wearable", EMBEDDED = "embedded", UA_MAX_LENGTH = 255; var AMAZON = "Amazon", APPLE = "Apple", ASUS = "ASUS", BLACKBERRY = "BlackBerry", BROWSER = "Browser", CHROME = "Chrome", EDGE = "Edge", FIREFOX = "Firefox", GOOGLE = "Google", HUAWEI = "Huawei", LG = "LG", MICROSOFT = "Microsoft", MOTOROLA = "Motorola", OPERA = "Opera", SAMSUNG = "Samsung", SONY = "Sony", XIAOMI = "Xiaomi", ZEBRA = "Zebra", FACEBOOK = "Facebook"; var extend = function (regexes, extensions) { var mergedRegexes = {}; for (var i in regexes) { if (extensions[i] && extensions[i].length % 2 === 0) { mergedRegexes[i] = extensions[i].concat(regexes[i]) } else { mergedRegexes[i] = regexes[i] } } return mergedRegexes }, enumerize = function (arr) { var enums = {}; for (var i = 0; i < arr.length; i++) { enums[arr[i].toUpperCase()] = arr[i] } return enums }, has = function (str1, str2) { return typeof str1 === STR_TYPE ? lowerize(str2).indexOf(lowerize(str1)) !== -1 : false }, lowerize = function (str) { return str.toLowerCase() }, majorize = function (version) { return typeof version === STR_TYPE ? version.replace(/[^\d\.]/g, EMPTY).split(".")[0] : undefined }, trim = function (str, len) { if (typeof str === STR_TYPE) { str = str.replace(/^\s\s*/, EMPTY).replace(/\s\s*$/, EMPTY); return typeof len === UNDEF_TYPE ? str : str.substring(0, UA_MAX_LENGTH) } }; var rgxMapper = function (ua, arrays) { var i = 0, j, k, p, q, matches, match; while (i < arrays.length && !matches) { var regex = arrays[i], props = arrays[i + 1]; j = k = 0; while (j < regex.length && !matches) { matches = regex[j++].exec(ua); if (!!matches) { for (p = 0; p < props.length; p++) { match = matches[++k]; q = props[p]; if (typeof q === OBJ_TYPE && q.length > 0) { if (q.length === 2) { if (typeof q[1] == FUNC_TYPE) { this[q[0]] = q[1].call(this, match) } else { this[q[0]] = q[1] } } else if (q.length === 3) { if (typeof q[1] === FUNC_TYPE && !(q[1].exec && q[1].test)) { this[q[0]] = match ? q[1].call(this, match, q[2]) : undefined } else { this[q[0]] = match ? match.replace(q[1], q[2]) : undefined } } else if (q.length === 4) { this[q[0]] = match ? q[3].call(this, match.replace(q[1], q[2])) : undefined } } else { this[q] = match ? match : undefined } } } } i += 2 } }, strMapper = function (str, map) { for (var i in map) { if (typeof map[i] === OBJ_TYPE && map[i].length > 0) { for (var j = 0; j < map[i].length; j++) { if (has(map[i][j], str)) { return i === UNKNOWN ? undefined : i } } } else if (has(map[i], str)) { return i === UNKNOWN ? undefined : i } } return str }; var oldSafariMap = { "1.0": "/8", 1.2: "/1", 1.3: "/3", "2.0": "/412", "2.0.2": "/416", "2.0.3": "/417", "2.0.4": "/419", "?": "/" }, windowsVersionMap = { ME: "4.90", "NT 3.11": "NT3.51", "NT 4.0": "NT4.0", 2e3: "NT 5.0", XP: ["NT 5.1", "NT 5.2"], Vista: "NT 6.0", 7: "NT 6.1", 8: "NT 6.2", 8.1: "NT 6.3", 10: ["NT 6.4", "NT 10.0"], RT: "ARM" }; var regexes = { browser: [[/\b(?:crmo|crios)\/([\w\.]+)/i], [VERSION, [NAME, "Chrome"]], [/edg(?:e|ios|a)?\/([\w\.]+)/i], [VERSION, [NAME, "Edge"]], [/(opera mini)\/([-\w\.]+)/i, /(opera [mobiletab]{3,6})\b.+version\/([-\w\.]+)/i, /(opera)(?:.+version\/|[\/ ]+)([\w\.]+)/i], [NAME, VERSION], [/opios[\/ ]+([\w\.]+)/i], [VERSION, [NAME, OPERA + " Mini"]], [/\bopr\/([\w\.]+)/i], [VERSION, [NAME, OPERA]], [/(kindle)\/([\w\.]+)/i, /(lunascape|maxthon|netfront|jasmine|blazer)[\/ ]?([\w\.]*)/i, /(avant |iemobile|slim)(?:browser)?[\/ ]?([\w\.]*)/i, /(ba?idubrowser)[\/ ]?([\w\.]+)/i, /(?:ms|\()(ie) ([\w\.]+)/i, /(flock|rockmelt|midori|epiphany|silk|skyfire|ovibrowser|bolt|iron|vivaldi|iridium|phantomjs|bowser|quark|qupzilla|falkon|rekonq|puffin|brave|whale|qqbrowserlite|qq)\/([-\w\.]+)/i, /(weibo)__([\d\.]+)/i], [NAME, VERSION], [/(?:\buc? ?browser|(?:juc.+)ucweb)[\/ ]?([\w\.]+)/i], [VERSION, [NAME, "UC" + BROWSER]], [/\bqbcore\/([\w\.]+)/i], [VERSION, [NAME, "WeChat(Win) Desktop"]], [/micromessenger\/([\w\.]+)/i], [VERSION, [NAME, "WeChat"]], [/konqueror\/([\w\.]+)/i], [VERSION, [NAME, "Konqueror"]], [/trident.+rv[: ]([\w\.]{1,9})\b.+like gecko/i], [VERSION, [NAME, "IE"]], [/yabrowser\/([\w\.]+)/i], [VERSION, [NAME, "Yandex"]], [/(avast|avg)\/([\w\.]+)/i], [[NAME, /(.+)/, "$1 Secure " + BROWSER], VERSION], [/\bfocus\/([\w\.]+)/i], [VERSION, [NAME, FIREFOX + " Focus"]], [/\bopt\/([\w\.]+)/i], [VERSION, [NAME, OPERA + " Touch"]], [/coc_coc\w+\/([\w\.]+)/i], [VERSION, [NAME, "Coc Coc"]], [/dolfin\/([\w\.]+)/i], [VERSION, [NAME, "Dolphin"]], [/coast\/([\w\.]+)/i], [VERSION, [NAME, OPERA + " Coast"]], [/miuibrowser\/([\w\.]+)/i], [VERSION, [NAME, "MIUI " + BROWSER]], [/fxios\/([-\w\.]+)/i], [VERSION, [NAME, FIREFOX]], [/\bqihu|(qi?ho?o?|360)browser/i], [[NAME, "360 " + BROWSER]], [/(oculus|samsung|sailfish)browser\/([\w\.]+)/i], [[NAME, /(.+)/, "$1 " + BROWSER], VERSION], [/(comodo_dragon)\/([\w\.]+)/i], [[NAME, /_/g, " "], VERSION], [/(electron)\/([\w\.]+) safari/i, /(tesla)(?: qtcarbrowser|\/(20\d\d\.[-\w\.]+))/i, /m?(qqbrowser|baiduboxapp|2345Explorer)[\/ ]?([\w\.]+)/i], [NAME, VERSION], [/(metasr)[\/ ]?([\w\.]+)/i, /(lbbrowser)/i], [NAME], [/((?:fban\/fbios|fb_iab\/fb4a)(?!.+fbav)|;fbav\/([\w\.]+);)/i], [[NAME, FACEBOOK], VERSION], [/safari (line)\/([\w\.]+)/i, /\b(line)\/([\w\.]+)\/iab/i, /(chromium|instagram)[\/ ]([-\w\.]+)/i], [NAME, VERSION], [/\bgsa\/([\w\.]+) .*safari\//i], [VERSION, [NAME, "GSA"]], [/headlesschrome(?:\/([\w\.]+)| )/i], [VERSION, [NAME, CHROME + " Headless"]], [/ wv\).+(chrome)\/([\w\.]+)/i], [[NAME, CHROME + " WebView"], VERSION], [/droid.+ version\/([\w\.]+)\b.+(?:mobile safari|safari)/i], [VERSION, [NAME, "Android " + BROWSER]], [/(chrome|omniweb|arora|[tizenoka]{5} ?browser)\/v?([\w\.]+)/i], [NAME, VERSION], [/version\/([\w\.]+) .*mobile\/\w+ (safari)/i], [VERSION, [NAME, "Mobile Safari"]], [/version\/([\w\.]+) .*(mobile ?safari|safari)/i], [VERSION, NAME], [/webkit.+?(mobile ?safari|safari)(\/[\w\.]+)/i], [NAME, [VERSION, strMapper, oldSafariMap]], [/(webkit|khtml)\/([\w\.]+)/i], [NAME, VERSION], [/(navigator|netscape\d?)\/([-\w\.]+)/i], [[NAME, "Netscape"], VERSION], [/mobile vr; rv:([\w\.]+)\).+firefox/i], [VERSION, [NAME, FIREFOX + " Reality"]], [/ekiohf.+(flow)\/([\w\.]+)/i, /(swiftfox)/i, /(icedragon|iceweasel|camino|chimera|fennec|maemo browser|minimo|conkeror|klar)[\/ ]?([\w\.\+]+)/i, /(seamonkey|k-meleon|icecat|iceape|firebird|phoenix|palemoon|basilisk|waterfox)\/([-\w\.]+)$/i, /(firefox)\/([\w\.]+)/i, /(mozilla)\/([\w\.]+) .+rv\:.+gecko\/\d+/i, /(polaris|lynx|dillo|icab|doris|amaya|w3m|netsurf|sleipnir|obigo|mosaic|(?:go|ice|up)[\. ]?browser)[-\/ ]?v?([\w\.]+)/i, /(links) \(([\w\.]+)/i], [NAME, VERSION]], cpu: [[/(?:(amd|x(?:(?:86|64)[-_])?|wow|win)64)[;\)]/i], [[ARCHITECTURE, "amd64"]], [/(ia32(?=;))/i], [[ARCHITECTURE, lowerize]], [/((?:i[346]|x)86)[;\)]/i], [[ARCHITECTURE, "ia32"]], [/\b(aarch64|arm(v?8e?l?|_?64))\b/i], [[ARCHITECTURE, "arm64"]], [/\b(arm(?:v[67])?ht?n?[fl]p?)\b/i], [[ARCHITECTURE, "armhf"]], [/windows (ce|mobile); ppc;/i], [[ARCHITECTURE, "arm"]], [/((?:ppc|powerpc)(?:64)?)(?: mac|;|\))/i], [[ARCHITECTURE, /ower/, EMPTY, lowerize]], [/(sun4\w)[;\)]/i], [[ARCHITECTURE, "sparc"]], [/((?:avr32|ia64(?=;))|68k(?=\))|\barm(?=v(?:[1-7]|[5-7]1)l?|;|eabi)|(?=atmel )avr|(?:irix|mips|sparc)(?:64)?\b|pa-risc)/i], [[ARCHITECTURE, lowerize]]], device: [[/\b(sch-i[89]0\d|shw-m380s|sm-[pt]\w{2,4}|gt-[pn]\d{2,4}|sgh-t8[56]9|nexus 10)/i], [MODEL, [VENDOR, SAMSUNG], [TYPE, TABLET]], [/\b((?:s[cgp]h|gt|sm)-\w+|galaxy nexus)/i, /samsung[- ]([-\w]+)/i, /sec-(sgh\w+)/i], [MODEL, [VENDOR, SAMSUNG], [TYPE, MOBILE]], [/\((ip(?:hone|od)[\w ]*);/i], [MODEL, [VENDOR, APPLE], [TYPE, MOBILE]], [/\((ipad);[-\w\),; ]+apple/i, /applecoremedia\/[\w\.]+ \((ipad)/i, /\b(ipad)\d\d?,\d\d?[;\]].+ios/i], [MODEL, [VENDOR, APPLE], [TYPE, TABLET]], [/\b((?:ag[rs][23]?|bah2?|sht?|btv)-a?[lw]\d{2})\b(?!.+d\/s)/i], [MODEL, [VENDOR, HUAWEI], [TYPE, TABLET]], [/(?:huawei|honor)([-\w ]+)[;\)]/i, /\b(nexus 6p|\w{2,4}-[atu]?[ln][01259x][012359][an]?)\b(?!.+d\/s)/i], [MODEL, [VENDOR, HUAWEI], [TYPE, MOBILE]], [/\b(poco[\w ]+)(?: bui|\))/i, /\b; (\w+) build\/hm\1/i, /\b(hm[-_ ]?note?[_ ]?(?:\d\w)?) bui/i, /\b(redmi[\-_ ]?(?:note|k)?[\w_ ]+)(?: bui|\))/i, /\b(mi[-_ ]?(?:a\d|one|one[_ ]plus|note lte|max)?[_ ]?(?:\d?\w?)[_ ]?(?:plus|se|lite)?)(?: bui|\))/i], [[MODEL, /_/g, " "], [VENDOR, XIAOMI], [TYPE, MOBILE]], [/\b(mi[-_ ]?(?:pad)(?:[\w_ ]+))(?: bui|\))/i], [[MODEL, /_/g, " "], [VENDOR, XIAOMI], [TYPE, TABLET]], [/; (\w+) bui.+ oppo/i, /\b(cph[12]\d{3}|p(?:af|c[al]|d\w|e[ar])[mt]\d0|x9007|a101op)\b/i], [MODEL, [VENDOR, "OPPO"], [TYPE, MOBILE]], [/vivo (\w+)(?: bui|\))/i, /\b(v[12]\d{3}\w?[at])(?: bui|;)/i], [MODEL, [VENDOR, "Vivo"], [TYPE, MOBILE]], [/\b(rmx[12]\d{3})(?: bui|;|\))/i], [MODEL, [VENDOR, "Realme"], [TYPE, MOBILE]], [/\b(milestone|droid(?:[2-4x]| (?:bionic|x2|pro|razr))?:?( 4g)?)\b[\w ]+build\//i, /\bmot(?:orola)?[- ](\w*)/i, /((?:moto[\w\(\) ]+|xt\d{3,4}|nexus 6)(?= bui|\)))/i], [MODEL, [VENDOR, MOTOROLA], [TYPE, MOBILE]], [/\b(mz60\d|xoom[2 ]{0,2}) build\//i], [MODEL, [VENDOR, MOTOROLA], [TYPE, TABLET]], [/((?=lg)?[vl]k\-?\d{3}) bui| 3\.[-\w; ]{10}lg?-([06cv9]{3,4})/i], [MODEL, [VENDOR, LG], [TYPE, TABLET]], [/(lm(?:-?f100[nv]?|-[\w\.]+)(?= bui|\))|nexus [45])/i, /\blg[-e;\/ ]+((?!browser|netcast|android tv)\w+)/i, /\blg-?([\d\w]+) bui/i], [MODEL, [VENDOR, LG], [TYPE, MOBILE]], [/(ideatab[-\w ]+)/i, /lenovo ?(s[56]000[-\w]+|tab(?:[\w ]+)|yt[-\d\w]{6}|tb[-\d\w]{6})/i], [MODEL, [VENDOR, "Lenovo"], [TYPE, TABLET]], [/(?:maemo|nokia).*(n900|lumia \d+)/i, /nokia[-_ ]?([-\w\.]*)/i], [[MODEL, /_/g, " "], [VENDOR, "Nokia"], [TYPE, MOBILE]], [/(pixel c)\b/i], [MODEL, [VENDOR, GOOGLE], [TYPE, TABLET]], [/droid.+; (pixel[\daxl ]{0,6})(?: bui|\))/i], [MODEL, [VENDOR, GOOGLE], [TYPE, MOBILE]], [/droid.+ ([c-g]\d{4}|so[-gl]\w+|xq-a\w[4-7][12])(?= bui|\).+chrome\/(?![1-6]{0,1}\d\.))/i], [MODEL, [VENDOR, SONY], [TYPE, MOBILE]], [/sony tablet [ps]/i, /\b(?:sony)?sgp\w+(?: bui|\))/i], [[MODEL, "Xperia Tablet"], [VENDOR, SONY], [TYPE, TABLET]], [/ (kb2005|in20[12]5|be20[12][59])\b/i, /(?:one)?(?:plus)? (a\d0\d\d)(?: b|\))/i], [MODEL, [VENDOR, "OnePlus"], [TYPE, MOBILE]], [/(alexa)webm/i, /(kf[a-z]{2}wi)( bui|\))/i, /(kf[a-z]+)( bui|\)).+silk\//i], [MODEL, [VENDOR, AMAZON], [TYPE, TABLET]], [/((?:sd|kf)[0349hijorstuw]+)( bui|\)).+silk\//i], [[MODEL, /(.+)/g, "Fire Phone $1"], [VENDOR, AMAZON], [TYPE, MOBILE]], [/(playbook);[-\w\),; ]+(rim)/i], [MODEL, VENDOR, [TYPE, TABLET]], [/\b((?:bb[a-f]|st[hv])100-\d)/i, /\(bb10; (\w+)/i], [MODEL, [VENDOR, BLACKBERRY], [TYPE, MOBILE]], [/(?:\b|asus_)(transfo[prime ]{4,10} \w+|eeepc|slider \w+|nexus 7|padfone|p00[cj])/i], [MODEL, [VENDOR, ASUS], [TYPE, TABLET]], [/ (z[bes]6[027][012][km][ls]|zenfone \d\w?)\b/i], [MODEL, [VENDOR, ASUS], [TYPE, MOBILE]], [/(nexus 9)/i], [MODEL, [VENDOR, "HTC"], [TYPE, TABLET]], [/(htc)[-;_ ]{1,2}([\w ]+(?=\)| bui)|\w+)/i, /(zte)[- ]([\w ]+?)(?: bui|\/|\))/i, /(alcatel|geeksphone|nexian|panasonic|sony)[-_ ]?([-\w]*)/i], [VENDOR, [MODEL, /_/g, " "], [TYPE, MOBILE]], [/droid.+; ([ab][1-7]-?[0178a]\d\d?)/i], [MODEL, [VENDOR, "Acer"], [TYPE, TABLET]], [/droid.+; (m[1-5] note) bui/i, /\bmz-([-\w]{2,})/i], [MODEL, [VENDOR, "Meizu"], [TYPE, MOBILE]], [/\b(sh-?[altvz]?\d\d[a-ekm]?)/i], [MODEL, [VENDOR, "Sharp"], [TYPE, MOBILE]], [/(blackberry|benq|palm(?=\-)|sonyericsson|acer|asus|dell|meizu|motorola|polytron)[-_ ]?([-\w]*)/i, /(hp) ([\w ]+\w)/i, /(asus)-?(\w+)/i, /(microsoft); (lumia[\w ]+)/i, /(lenovo)[-_ ]?([-\w]+)/i, /(jolla)/i, /(oppo) ?([\w ]+) bui/i], [VENDOR, MODEL, [TYPE, MOBILE]], [/(archos) (gamepad2?)/i, /(hp).+(touchpad(?!.+tablet)|tablet)/i, /(kindle)\/([\w\.]+)/i, /(nook)[\w ]+build\/(\w+)/i, /(dell) (strea[kpr\d ]*[\dko])/i, /(le[- ]+pan)[- ]+(\w{1,9}) bui/i, /(trinity)[- ]*(t\d{3}) bui/i, /(gigaset)[- ]+(q\w{1,9}) bui/i, /(vodafone) ([\w ]+)(?:\)| bui)/i], [VENDOR, MODEL, [TYPE, TABLET]], [/(surface duo)/i], [MODEL, [VENDOR, MICROSOFT], [TYPE, TABLET]], [/droid [\d\.]+; (fp\du?)(?: b|\))/i], [MODEL, [VENDOR, "Fairphone"], [TYPE, MOBILE]], [/(u304aa)/i], [MODEL, [VENDOR, "AT&T"], [TYPE, MOBILE]], [/\bsie-(\w*)/i], [MODEL, [VENDOR, "Siemens"], [TYPE, MOBILE]], [/\b(rct\w+) b/i], [MODEL, [VENDOR, "RCA"], [TYPE, TABLET]], [/\b(venue[\d ]{2,7}) b/i], [MODEL, [VENDOR, "Dell"], [TYPE, TABLET]], [/\b(q(?:mv|ta)\w+) b/i], [MODEL, [VENDOR, "Verizon"], [TYPE, TABLET]], [/\b(?:barnes[& ]+noble |bn[rt])([\w\+ ]*) b/i], [MODEL, [VENDOR, "Barnes & Noble"], [TYPE, TABLET]], [/\b(tm\d{3}\w+) b/i], [MODEL, [VENDOR, "NuVision"], [TYPE, TABLET]], [/\b(k88) b/i], [MODEL, [VENDOR, "ZTE"], [TYPE, TABLET]], [/\b(nx\d{3}j) b/i], [MODEL, [VENDOR, "ZTE"], [TYPE, MOBILE]], [/\b(gen\d{3}) b.+49h/i], [MODEL, [VENDOR, "Swiss"], [TYPE, MOBILE]], [/\b(zur\d{3}) b/i], [MODEL, [VENDOR, "Swiss"], [TYPE, TABLET]], [/\b((zeki)?tb.*\b) b/i], [MODEL, [VENDOR, "Zeki"], [TYPE, TABLET]], [/\b([yr]\d{2}) b/i, /\b(dragon[- ]+touch |dt)(\w{5}) b/i], [[VENDOR, "Dragon Touch"], MODEL, [TYPE, TABLET]], [/\b(ns-?\w{0,9}) b/i], [MODEL, [VENDOR, "Insignia"], [TYPE, TABLET]], [/\b((nxa|next)-?\w{0,9}) b/i], [MODEL, [VENDOR, "NextBook"], [TYPE, TABLET]], [/\b(xtreme\_)?(v(1[045]|2[015]|[3469]0|7[05])) b/i], [[VENDOR, "Voice"], MODEL, [TYPE, MOBILE]], [/\b(lvtel\-)?(v1[12]) b/i], [[VENDOR, "LvTel"], MODEL, [TYPE, MOBILE]], [/\b(ph-1) /i], [MODEL, [VENDOR, "Essential"], [TYPE, MOBILE]], [/\b(v(100md|700na|7011|917g).*\b) b/i], [MODEL, [VENDOR, "Envizen"], [TYPE, TABLET]], [/\b(trio[-\w\. ]+) b/i], [MODEL, [VENDOR, "MachSpeed"], [TYPE, TABLET]], [/\btu_(1491) b/i], [MODEL, [VENDOR, "Rotor"], [TYPE, TABLET]], [/(shield[\w ]+) b/i], [MODEL, [VENDOR, "Nvidia"], [TYPE, TABLET]], [/(sprint) (\w+)/i], [VENDOR, MODEL, [TYPE, MOBILE]], [/(kin\.[onetw]{3})/i], [[MODEL, /\./g, " "], [VENDOR, MICROSOFT], [TYPE, MOBILE]], [/droid.+; (cc6666?|et5[16]|mc[239][23]x?|vc8[03]x?)\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, TABLET]], [/droid.+; (ec30|ps20|tc[2-8]\d[kx])\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, MOBILE]], [/(ouya)/i, /(nintendo) ([wids3utch]+)/i], [VENDOR, MODEL, [TYPE, CONSOLE]], [/droid.+; (shield) bui/i], [MODEL, [VENDOR, "Nvidia"], [TYPE, CONSOLE]], [/(playstation [345portablevi]+)/i], [MODEL, [VENDOR, SONY], [TYPE, CONSOLE]], [/\b(xbox(?: one)?(?!; xbox))[\); ]/i], [MODEL, [VENDOR, MICROSOFT], [TYPE, CONSOLE]], [/smart-tv.+(samsung)/i], [VENDOR, [TYPE, SMARTTV]], [/hbbtv.+maple;(\d+)/i], [[MODEL, /^/, "SmartTV"], [VENDOR, SAMSUNG], [TYPE, SMARTTV]], [/(nux; netcast.+smarttv|lg (netcast\.tv-201\d|android tv))/i], [[VENDOR, LG], [TYPE, SMARTTV]], [/(apple) ?tv/i], [VENDOR, [MODEL, APPLE + " TV"], [TYPE, SMARTTV]], [/crkey/i], [[MODEL, CHROME + "cast"], [VENDOR, GOOGLE], [TYPE, SMARTTV]], [/droid.+aft(\w)( bui|\))/i], [MODEL, [VENDOR, AMAZON], [TYPE, SMARTTV]], [/\(dtv[\);].+(aquos)/i], [MODEL, [VENDOR, "Sharp"], [TYPE, SMARTTV]], [/\b(roku)[\dx]*[\)\/]((?:dvp-)?[\d\.]*)/i, /hbbtv\/\d+\.\d+\.\d+ +\([\w ]*; *(\w[^;]*);([^;]*)/i], [[VENDOR, trim], [MODEL, trim], [TYPE, SMARTTV]], [/\b(android tv|smart[- ]?tv|opera tv|tv; rv:)\b/i], [[TYPE, SMARTTV]], [/((pebble))app/i], [VENDOR, MODEL, [TYPE, WEARABLE]], [/droid.+; (glass) \d/i], [MODEL, [VENDOR, GOOGLE], [TYPE, WEARABLE]], [/droid.+; (wt63?0{2,3})\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, WEARABLE]], [/(quest( 2)?)/i], [MODEL, [VENDOR, FACEBOOK], [TYPE, WEARABLE]], [/(tesla)(?: qtcarbrowser|\/[-\w\.]+)/i], [VENDOR, [TYPE, EMBEDDED]], [/droid .+?; ([^;]+?)(?: bui|\) applew).+? mobile safari/i], [MODEL, [TYPE, MOBILE]], [/droid .+?; ([^;]+?)(?: bui|\) applew).+?(?! mobile) safari/i], [MODEL, [TYPE, TABLET]], [/\b((tablet|tab)[;\/]|focus\/\d(?!.+mobile))/i], [[TYPE, TABLET]], [/(phone|mobile(?:[;\/]| safari)|pda(?=.+windows ce))/i], [[TYPE, MOBILE]], [/(android[-\w\. ]{0,9});.+buil/i], [MODEL, [VENDOR, "Generic"]]], engine: [[/windows.+ edge\/([\w\.]+)/i], [VERSION, [NAME, EDGE + "HTML"]], [/webkit\/537\.36.+chrome\/(?!27)([\w\.]+)/i], [VERSION, [NAME, "Blink"]], [/(presto)\/([\w\.]+)/i, /(webkit|trident|netfront|netsurf|amaya|lynx|w3m|goanna)\/([\w\.]+)/i, /ekioh(flow)\/([\w\.]+)/i, /(khtml|tasman|links)[\/ ]\(?([\w\.]+)/i, /(icab)[\/ ]([23]\.[\d\.]+)/i], [NAME, VERSION], [/rv\:([\w\.]{1,9})\b.+(gecko)/i], [VERSION, NAME]], os: [[/microsoft (windows) (vista|xp)/i], [NAME, VERSION], [/(windows) nt 6\.2; (arm)/i, /(windows (?:phone(?: os)?|mobile))[\/ ]?([\d\.\w ]*)/i, /(windows)[\/ ]?([ntce\d\. ]+\w)(?!.+xbox)/i], [NAME, [VERSION, strMapper, windowsVersionMap]], [/(win(?=3|9|n)|win 9x )([nt\d\.]+)/i], [[NAME, "Windows"], [VERSION, strMapper, windowsVersionMap]], [/ip[honead]{2,4}\b(?:.*os ([\w]+) like mac|; opera)/i, /cfnetwork\/.+darwin/i], [[VERSION, /_/g, "."], [NAME, "iOS"]], [/(mac os x) ?([\w\. ]*)/i, /(macintosh|mac_powerpc\b)(?!.+haiku)/i], [[NAME, "Mac OS"], [VERSION, /_/g, "."]], [/droid ([\w\.]+)\b.+(android[- ]x86)/i], [VERSION, NAME], [/(android|webos|qnx|bada|rim tablet os|maemo|meego|sailfish)[-\/ ]?([\w\.]*)/i, /(blackberry)\w*\/([\w\.]*)/i, /(tizen|kaios)[\/ ]([\w\.]+)/i, /\((series40);/i], [NAME, VERSION], [/\(bb(10);/i], [VERSION, [NAME, BLACKBERRY]], [/(?:symbian ?os|symbos|s60(?=;)|series60)[-\/ ]?([\w\.]*)/i], [VERSION, [NAME, "Symbian"]], [/mozilla\/[\d\.]+ \((?:mobile|tablet|tv|mobile; [\w ]+); rv:.+ gecko\/([\w\.]+)/i], [VERSION, [NAME, FIREFOX + " OS"]], [/web0s;.+rt(tv)/i, /\b(?:hp)?wos(?:browser)?\/([\w\.]+)/i], [VERSION, [NAME, "webOS"]], [/crkey\/([\d\.]+)/i], [VERSION, [NAME, CHROME + "cast"]], [/(cros) [\w]+ ([\w\.]+\w)/i], [[NAME, "Chromium OS"], VERSION], [/(nintendo|playstation) ([wids345portablevuch]+)/i, /(xbox); +xbox ([^\);]+)/i, /\b(joli|palm)\b ?(?:os)?\/?([\w\.]*)/i, /(mint)[\/\(\) ]?(\w*)/i, /(mageia|vectorlinux)[; ]/i, /([kxln]?ubuntu|debian|suse|opensuse|gentoo|arch(?= linux)|slackware|fedora|mandriva|centos|pclinuxos|red ?hat|zenwalk|linpus|raspbian|plan 9|minix|risc os|contiki|deepin|manjaro|elementary os|sabayon|linspire)(?: gnu\/linux)?(?: enterprise)?(?:[- ]linux)?(?:-gnu)?[-\/ ]?(?!chrom|package)([-\w\.]*)/i, /(hurd|linux) ?([\w\.]*)/i, /(gnu) ?([\w\.]*)/i, /\b([-frentopcghs]{0,5}bsd|dragonfly)[\/ ]?(?!amd|[ix346]{1,2}86)([\w\.]*)/i, /(haiku) (\w+)/i], [NAME, VERSION], [/(sunos) ?([\w\.\d]*)/i], [[NAME, "Solaris"], VERSION], [/((?:open)?solaris)[-\/ ]?([\w\.]*)/i, /(aix) ((\d)(?=\.|\)| )[\w\.])*/i, /\b(beos|os\/2|amigaos|morphos|openvms|fuchsia|hp-ux)/i, /(unix) ?([\w\.]*)/i], [NAME, VERSION]] }; var UAParser = function (ua, extensions) { if (typeof ua === OBJ_TYPE) { extensions = ua; ua = undefined } if (!(this instanceof UAParser)) { return new UAParser(ua, extensions).getResult() } var _ua = ua || (typeof window !== UNDEF_TYPE && window.navigator && window.navigator.userAgent ? window.navigator.userAgent : EMPTY); var _rgxmap = extensions ? extend(regexes, extensions) : regexes; this.getBrowser = function () { var _browser = {}; _browser[NAME] = undefined; _browser[VERSION] = undefined; rgxMapper.call(_browser, _ua, _rgxmap.browser); _browser.major = majorize(_browser.version); return _browser }; this.getCPU = function () { var _cpu = {}; _cpu[ARCHITECTURE] = undefined; rgxMapper.call(_cpu, _ua, _rgxmap.cpu); return _cpu }; this.getDevice = function () { var _device = {}; _device[VENDOR] = undefined; _device[MODEL] = undefined; _device[TYPE] = undefined; rgxMapper.call(_device, _ua, _rgxmap.device); return _device }; this.getEngine = function () { var _engine = {}; _engine[NAME] = undefined; _engine[VERSION] = undefined; rgxMapper.call(_engine, _ua, _rgxmap.engine); return _engine }; this.getOS = function () { var _os = {}; _os[NAME] = undefined; _os[VERSION] = undefined; rgxMapper.call(_os, _ua, _rgxmap.os); return _os }; this.getResult = function () { return { ua: this.getUA(), browser: this.getBrowser(), engine: this.getEngine(), os: this.getOS(), device: this.getDevice(), cpu: this.getCPU() } }; this.getUA = function () { return _ua }; this.setUA = function (ua) { _ua = typeof ua === STR_TYPE && ua.length > UA_MAX_LENGTH ? trim(ua, UA_MAX_LENGTH) : ua; return this }; this.setUA(_ua); return this }; UAParser.VERSION = LIBVERSION; UAParser.BROWSER = enumerize([NAME, VERSION, MAJOR]); UAParser.CPU = enumerize([ARCHITECTURE]); UAParser.DEVICE = enumerize([MODEL, VENDOR, TYPE, CONSOLE, MOBILE, SMARTTV, TABLET, WEARABLE, EMBEDDED]); UAParser.ENGINE = UAParser.OS = enumerize([NAME, VERSION]); if (typeof exports !== UNDEF_TYPE) { if (typeof module !== UNDEF_TYPE && module.exports) { exports = module.exports = UAParser } exports.UAParser = UAParser } else { if (typeof define === FUNC_TYPE && define.amd) { define(function () { return UAParser }) } else if (typeof window !== UNDEF_TYPE) { window.UAParser = UAParser } } var $ = typeof window !== UNDEF_TYPE && (window.jQuery || window.Zepto); if ($ && !$.ua) { var parser = new UAParser; $.ua = parser.getResult(); $.ua.get = function () { return parser.getUA() }; $.ua.set = function (ua) { parser.setUA(ua); var result = parser.getResult(); for (var prop in result) { $.ua[prop] = result[prop] } } } })(typeof window === "object" ? window : this);
\ No newline at end of file
diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js
index 50d1ecd0..5435c29a 100644
--- a/server/managers/PlaybackSessionManager.js
+++ b/server/managers/PlaybackSessionManager.js
@@ -1,11 +1,16 @@
const Path = require('path')
const date = require('date-and-time')
+const serverVersion = require('../../package.json').version
const { PlayMethod } = require('../utils/constants')
const PlaybackSession = require('../objects/PlaybackSession')
+const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream')
const Logger = require('../Logger')
const fs = require('fs-extra')
+const uaParserJs = require('../libs/uaParserJs')
+const requestIp = require('../libs/requestIp')
+
class PlaybackSessionManager {
constructor(db, emitter, clientEmitter) {
this.db = db
@@ -27,8 +32,21 @@ class PlaybackSessionManager {
return session ? session.stream : null
}
- async startSessionRequest(user, libraryItem, episodeId, options, res) {
- const session = await this.startSession(user, libraryItem, episodeId, options)
+ getDeviceInfo(req) {
+ const ua = uaParserJs(req.headers['user-agent'])
+ const ip = requestIp.getClientIp(req)
+ const clientDeviceInfo = req.body ? req.body.deviceInfo || null : null // From mobile client
+
+ const deviceInfo = new DeviceInfo()
+ deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
+ return deviceInfo
+ }
+
+ async startSessionRequest(req, res, episodeId) {
+ const deviceInfo = this.getDeviceInfo(req)
+
+ const { user, libraryItem, body: options } = req
+ const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
res.json(session.toJSONForClient(libraryItem))
}
@@ -84,7 +102,7 @@ class PlaybackSessionManager {
res.sendStatus(200)
}
- async startSession(user, libraryItem, episodeId, options) {
+ async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user
var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
for (const session of userSessions) {
@@ -99,7 +117,7 @@ class PlaybackSessionManager {
var userStartTime = 0
if (userProgress) userStartTime = Number.parseFloat(userProgress.currentTime) || 0
const newPlaybackSession = new PlaybackSession()
- newPlaybackSession.setData(libraryItem, user, mediaPlayer, episodeId)
+ newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
var audioTracks = []
if (shouldDirectPlay) {
@@ -122,7 +140,6 @@ class PlaybackSessionManager {
})
}
- newPlaybackSession.currentTime = userStartTime
newPlaybackSession.audioTracks = audioTracks
// Will save on the first sync
diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js
new file mode 100644
index 00000000..2f97d2ad
--- /dev/null
+++ b/server/objects/DeviceInfo.js
@@ -0,0 +1,74 @@
+class DeviceInfo {
+ constructor(deviceInfo = null) {
+ this.ipAddress = null
+
+ // From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
+ this.browserName = null
+ this.browserVersion = null
+ this.osName = null
+ this.osVersion = null
+ this.deviceType = null
+
+ // From client
+ this.clientVersion = null
+ this.manufacturer = null
+ this.model = null
+ this.sdkVersion = null // Android Only
+
+ this.serverVersion = null
+
+ if (deviceInfo) {
+ this.construct(deviceInfo)
+ }
+ }
+
+ construct(deviceInfo) {
+ for (const key in deviceInfo) {
+ if (deviceInfo[key] !== undefined && this[key] !== undefined) {
+ this[key] = deviceInfo[key]
+ }
+ }
+ }
+
+ toJSON() {
+ const obj = {
+ ipAddress: this.ipAddress,
+ browserName: this.browserName,
+ browserVersion: this.browserVersion,
+ osName: this.osName,
+ osVersion: this.osVersion,
+ deviceType: this.deviceType,
+ clientVersion: this.clientVersion,
+ manufacturer: this.manufacturer,
+ model: this.model,
+ sdkVersion: this.sdkVersion,
+ serverVersion: this.serverVersion
+ }
+ for (const key in obj) {
+ if (obj[key] === null || obj[key] === undefined) {
+ delete obj[key]
+ }
+ }
+ return obj
+ }
+
+ setData(ip, ua, clientDeviceInfo, serverVersion) {
+ this.ipAddress = ip || null
+
+ const uaObj = ua || {}
+ this.browserName = uaObj.browser.name || null
+ this.browserVersion = uaObj.browser.version || null
+ this.osName = uaObj.os.name || null
+ this.osVersion = uaObj.os.version || null
+ this.deviceType = uaObj.device.type || null
+
+ var cdi = clientDeviceInfo || {}
+ this.clientVersion = cdi.clientVersion || null
+ this.manufacturer = cdi.manufacturer || null
+ this.model = cdi.model || null
+ this.sdkVersion = cdi.sdkVersion || null
+
+ this.serverVersion = serverVersion || null
+ }
+}
+module.exports = DeviceInfo
\ No newline at end of file
diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js
index 34311928..5b69628a 100644
--- a/server/objects/PlaybackSession.js
+++ b/server/objects/PlaybackSession.js
@@ -3,11 +3,13 @@ const { getId } = require('../utils/index')
const { PlayMethod } = require('../utils/constants')
const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata')
+const DeviceInfo = require('./DeviceInfo')
class PlaybackSession {
constructor(session) {
this.id = null
this.userId = null
+ this.libraryId = null
this.libraryItemId = null
this.episodeId = null
@@ -21,18 +23,21 @@ class PlaybackSession {
this.playMethod = null
this.mediaPlayer = null
+ this.deviceInfo = null
this.date = null
this.dayOfWeek = null
this.timeListening = null
+ this.startTime = null // media current time at start of playback
+ this.currentTime = 0 // Last current time set
+
this.startedAt = null
this.updatedAt = null
// Not saved in DB
this.lastSave = 0
this.audioTracks = []
- this.currentTime = 0
this.stream = null
if (session) {
@@ -43,8 +48,8 @@ class PlaybackSession {
toJSON() {
return {
id: this.id,
- sessionType: this.sessionType,
userId: this.userId,
+ libraryId: this.libraryId,
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
mediaType: this.mediaType,
@@ -56,10 +61,13 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
+ deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
- lastUpdate: this.lastUpdate,
+ startTime: this.startTime,
+ currentTime: this.currentTime,
+ startedAt: this.startedAt,
updatedAt: this.updatedAt
}
}
@@ -67,8 +75,8 @@ class PlaybackSession {
toJSONForClient(libraryItem) {
return {
id: this.id,
- sessionType: this.sessionType,
userId: this.userId,
+ libraryId: this.libraryId,
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
mediaType: this.mediaType,
@@ -80,27 +88,30 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
+ deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
- lastUpdate: this.lastUpdate,
+ startTime: this.startTime,
+ currentTime: this.currentTime,
+ startedAt: this.startedAt,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map(at => at.toJSON()),
- currentTime: this.currentTime,
libraryItem: libraryItem.toJSONExpanded()
}
}
construct(session) {
this.id = session.id
- this.sessionType = session.sessionType
this.userId = session.userId
+ this.libraryId = session.libraryId || null
this.libraryItemId = session.libraryItemId
this.episodeId = session.episodeId
this.mediaType = session.mediaType
this.duration = session.duration
this.playMethod = session.playMethod
this.mediaPlayer = session.mediaPlayer || null
+ this.deviceInfo = new DeviceInfo(session.deviceInfo)
this.chapters = session.chapters || []
this.mediaMetadata = null
@@ -118,6 +129,9 @@ class PlaybackSession {
this.dayOfWeek = session.dayOfWeek
this.timeListening = session.timeListening || null
+ this.startTime = session.startTime || 0
+ this.currentTime = session.currentTime || 0
+
this.startedAt = session.startedAt
this.updatedAt = session.updatedAt || null
}
@@ -127,9 +141,10 @@ class PlaybackSession {
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
}
- setData(libraryItem, user, mediaPlayer, episodeId = null) {
+ setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) {
this.id = getId('play')
this.userId = user.id
+ this.libraryId = libraryItem.libraryId
this.libraryItemId = libraryItem.id
this.episodeId = episodeId
this.mediaType = libraryItem.mediaType
@@ -146,8 +161,12 @@ class PlaybackSession {
}
this.mediaPlayer = mediaPlayer
+ this.deviceInfo = deviceInfo || new DeviceInfo()
this.timeListening = 0
+ this.startTime = startTime
+ this.currentTime = startTime
+
this.date = date.format(new Date(), 'YYYY-MM-DD')
this.dayOfWeek = date.format(new Date(), 'dddd')
this.startedAt = Date.now()
diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js
index 28c232d2..988421db 100644
--- a/server/objects/entities/PodcastEpisode.js
+++ b/server/objects/entities/PodcastEpisode.js
@@ -1,4 +1,3 @@
-const { stripHtml } = require('string-strip-html')
const { getId } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
@@ -78,8 +77,7 @@ class PodcastEpisode {
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
- // description: this.description,
- description: this.descriptionPlain, // Temporary stripping HTML until proper cleaning is implemented
+ description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate,
audioFile: this.audioFile.toJSON(),
@@ -108,10 +106,6 @@ class PodcastEpisode {
if (this.episode) return `${this.episode} - ${this.title}`
return this.title
}
- get descriptionPlain() {
- if (!this.description) return ''
- return stripHtml(this.description).result
- }
setData(data, index = 1) {
this.id = getId('ep')
diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js
index 55ae0c15..a6e38c51 100644
--- a/server/objects/mediaTypes/Podcast.js
+++ b/server/objects/mediaTypes/Podcast.js
@@ -224,18 +224,10 @@ class Podcast {
this.episodes.push(pe)
}
- setEpisodeOrder(episodeIds) {
- episodeIds.reverse() // episode Ids will already be in descending order
- this.episodes = this.episodes.map(ep => {
- var indexOf = episodeIds.findIndex(id => id === ep.id)
- ep.index = indexOf + 1
- return ep
- })
- this.episodes.sort((a, b) => b.index - a.index)
- }
-
reorderEpisodes() {
var hasUpdates = false
+
+ // TODO: Sort by published date
this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename)
for (let i = 0; i < this.episodes.length; i++) {
if (this.episodes[i].index !== (i + 1)) {
diff --git a/server/providers/Audible.js b/server/providers/Audible.js
index 9c1e1a97..6934cebb 100644
--- a/server/providers/Audible.js
+++ b/server/providers/Audible.js
@@ -1,16 +1,16 @@
const axios = require('axios')
-const { stripHtml } = require('string-strip-html')
+const htmlSanitizer = require('../utils/htmlSanitizer')
const Logger = require('../Logger')
class Audible {
constructor() { }
cleanResult(item) {
- var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language } = item;
+ var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language } = item
var series = []
- if(seriesPrimary) series.push(seriesPrimary)
- if(seriesSecondary) series.push(seriesSecondary)
+ if (seriesPrimary) series.push(seriesPrimary)
+ if (seriesSecondary) series.push(seriesSecondary)
var genresFiltered = genres ? genres.filter(g => g.type == "genre") : []
var tagsFiltered = genres ? genres.filter(g => g.type == "tag") : []
@@ -22,12 +22,12 @@ class Audible {
narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null,
publisher: publisherName,
publishedYear: releaseDate ? releaseDate.split('-')[0] : null,
- description: summary ? stripHtml(summary).result : null,
+ description: summary ? htmlSanitizer.stripAllTags(summary) : null,
cover: image,
asin,
genres: genresFiltered.length > 0 ? genresFiltered.map(({ name }) => name).join(', ') : null,
tags: tagsFiltered.length > 0 ? tagsFiltered.map(({ name }) => name).join(', ') : null,
- series: series != [] ? series.map(({name, position}) => ({ series: name, volumeNumber: position })) : null,
+ series: series != [] ? series.map(({ name, position }) => ({ series: name, volumeNumber: position })) : null,
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null
}
}
@@ -49,17 +49,17 @@ class Audible {
})
}
- async search(title, author, asin) {
+ async search(title, author, asin) {
var items
- if(asin) {
+ if (asin) {
items = [await this.asinSearch(asin)]
}
-
+
if (!items && this.isProbablyAsin(title)) {
items = [await this.asinSearch(title)]
}
- if(!items) {
+ if (!items) {
var queryObj = {
num_results: '10',
products_sort_by: 'Relevance',
diff --git a/server/providers/iTunes.js b/server/providers/iTunes.js
index 7f43d819..5bf33fe5 100644
--- a/server/providers/iTunes.js
+++ b/server/providers/iTunes.js
@@ -1,6 +1,7 @@
const axios = require('axios')
const Logger = require('../Logger')
-const { stripHtml } = require('string-strip-html')
+const htmlSanitizer = require('../utils/htmlSanitizer')
+
class iTunes {
constructor() { }
@@ -64,7 +65,7 @@ class iTunes {
artistId: data.artistId,
title: data.collectionName,
author: data.artistName,
- description: stripHtml(data.description || '').result,
+ description: htmlSanitizer.stripAllTags(data.description || ''),
publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null,
genres: data.primaryGenreName ? [data.primaryGenreName] : [],
cover: this.getCoverArtwork(data)
@@ -83,7 +84,8 @@ class iTunes {
artistId: data.artistId || null,
title: data.collectionName,
artistName: data.artistName,
- description: stripHtml(data.description || '').result,
+ description: htmlSanitizer.sanitize(data.description || ''),
+ descriptionPlain: htmlSanitizer.stripAllTags(data.description || ''),
releaseDate: data.releaseDate,
genres: data.genres || [],
cover: this.getCoverArtwork(data),
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index d2b67114..638dee7d 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -90,8 +90,6 @@ class ApiRouter {
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
- this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
- this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
@@ -111,7 +109,7 @@ class ApiRouter {
this.router.patch('/users/:id', UserController.update.bind(this))
this.router.delete('/users/:id', UserController.delete.bind(this))
- this.router.get('/users/:id/listening-sessions', UserController.getListeningStats.bind(this))
+ this.router.get('/users/:id/listening-sessions', UserController.getListeningSessions.bind(this))
this.router.get('/users/:id/listening-stats', UserController.getListeningStats.bind(this))
//
@@ -189,6 +187,7 @@ class ApiRouter {
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
+ this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
//
// Misc Routes
diff --git a/server/routers/StaticRouter.js b/server/routers/StaticRouter.js
index b571869f..24b6f6da 100644
--- a/server/routers/StaticRouter.js
+++ b/server/routers/StaticRouter.js
@@ -17,7 +17,9 @@ class StaticRouter {
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
var remainingPath = req.params['0']
- var fullPath = Path.join(item.path, remainingPath)
+ var fullPath = null
+ if (item.isFile) fullPath = item.path
+ else fullPath = Path.join(item.path, remainingPath)
res.sendFile(fullPath)
})
}
diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js
index 3ded0c10..aa8582be 100644
--- a/server/scanner/Scanner.js
+++ b/server/scanner/Scanner.js
@@ -62,7 +62,8 @@ class Scanner {
}
async scanLibraryItem(libraryMediaType, folder, libraryItem) {
- var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, this.db.serverSettings)
+ // TODO: Support for single media item
+ var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings)
if (!libraryItemData) {
return ScanResult.NOTHING
}
@@ -499,7 +500,11 @@ class Scanner {
continue;
}
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
- var fileUpdateGroup = groupFilesIntoLibraryItemPaths(relFilePaths, true)
+ var fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
+ if (!Object.keys(fileUpdateGroup).length) {
+ Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
+ continue;
+ }
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
}
@@ -513,6 +518,8 @@ class Scanner {
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
var updateGroup = { ...fileUpdateGroup }
for (const itemDir in updateGroup) {
+ if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path
+
var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
if (!itemDirNestedFiles.length) continue;
@@ -582,7 +589,8 @@ class Scanner {
}
Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
- var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath)
+ var isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
+ var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath, isSingleMediaItem)
if (newLibraryItem) {
await this.createNewAuthorsAndSeries(newLibraryItem)
await this.db.insertLibraryItem(newLibraryItem)
@@ -594,8 +602,8 @@ class Scanner {
return itemGroupingResults
}
- async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath) {
- var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, this.db.serverSettings)
+ async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) {
+ var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
if (!libraryItemData) return null
var serverSettings = this.db.serverSettings
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers)
diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js
new file mode 100644
index 00000000..bfd0a619
--- /dev/null
+++ b/server/utils/htmlSanitizer.js
@@ -0,0 +1,28 @@
+const sanitizeHtml = require('../libs/sanitizeHtml')
+
+function sanitize(html) {
+ const sanitizerOptions = {
+ allowedTags: [
+ 'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del'
+ ],
+ disallowedTagsMode: 'discard',
+ allowedAttributes: {
+ a: ['href', 'name', 'target']
+ },
+ allowedSchemes: ['https'],
+ allowProtocolRelative: false
+ }
+
+ return sanitizeHtml(html, sanitizerOptions)
+}
+module.exports.sanitize = sanitize
+
+function stripAllTags(html) {
+ const sanitizerOptions = {
+ allowedTags: [],
+ disallowedTagsMode: 'discard'
+ }
+
+ return sanitizeHtml(html, sanitizerOptions)
+}
+module.exports.stripAllTags = stripAllTags
\ No newline at end of file
diff --git a/server/utils/parseOpfMetadata.js b/server/utils/parseOpfMetadata.js
index 95d74775..ceac6047 100644
--- a/server/utils/parseOpfMetadata.js
+++ b/server/utils/parseOpfMetadata.js
@@ -1,5 +1,5 @@
const { xmlToJSON } = require('./index')
-const { stripHtml } = require("string-strip-html")
+const htmlSanitizer = require('./htmlSanitizer')
function parseCreators(metadata) {
if (!metadata['dc:creator']) return null
@@ -57,8 +57,7 @@ function fetchDescription(metadata) {
// check if description is HTML or plain text. only plain text allowed
// calibre stores < and > as < and >
description = description.replace(/</g, '<').replace(/>/g, '>')
- if (description.match(/|<\/?\s*[a-z-][^>]*\s*>|(\&(?:[\w\d]+|#\d+|#x[a-f\d]+);)/)) return stripHtml(description).result
- return description
+ return htmlSanitizer.stripAllTags(description)
}
function fetchGenres(metadata) {
diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js
index 28c874cc..a5c2ab4e 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -1,6 +1,6 @@
const Logger = require('../Logger')
const { xmlToJSON } = require('./index')
-const { stripHtml } = require('string-strip-html')
+const htmlSanitizer = require('../utils/htmlSanitizer')
function extractFirstArrayItem(json, key) {
if (!json[key] || !json[key].length) return null
@@ -55,8 +55,9 @@ function extractPodcastMetadata(channel) {
}
if (channel['description']) {
- metadata.description = extractFirstArrayItem(channel, 'description')
- metadata.descriptionPlain = stripHtml(metadata.description || '').result
+ const rawDescription = extractFirstArrayItem(channel, 'description') || ''
+ metadata.description = htmlSanitizer.sanitize(rawDescription)
+ metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
}
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link']
@@ -80,9 +81,17 @@ function extractEpisodeData(item) {
}
}
+ // Full description with html
+ if (item['content:encoded']) {
+ const rawDescription = (extractFirstArrayItem(item, 'content:encoded') || '').trim()
+ episode.description = htmlSanitizer.sanitize(rawDescription)
+ }
+
+ // Supposed to be the plaintext description but not always followed
if (item['description']) {
- episode.description = extractFirstArrayItem(item, 'description')
- episode.descriptionPlain = stripHtml(episode.description || '').result
+ const rawDescription = extractFirstArrayItem(item, 'description') || ''
+ if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription)
+ episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
}
var arrayFields = ['title', 'pubDate', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
diff --git a/server/utils/scandir.js b/server/utils/scandir.js
index 699a47b9..d3c73269 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -17,11 +17,14 @@ function isMediaFile(mediaType, ext) {
// TODO: Function needs to be re-done
// Input: array of relative file paths
// Output: map of files grouped into potential item dirs
-function groupFilesIntoLibraryItemPaths(paths) {
- // Step 1: Clean path, Remove leading "/", Filter out files in root dir
+function groupFilesIntoLibraryItemPaths(mediaType, paths) {
+ // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
var pathsFiltered = paths.map(path => {
return path.startsWith('/') ? path.slice(1) : path
- }).filter(path => Path.parse(path).dir)
+ }).filter(path => {
+ let parsedPath = Path.parse(path)
+ return parsedPath.dir || (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext))
+ })
// Step 2: Sort by least number of directories
pathsFiltered.sort((a, b) => {
@@ -33,25 +36,30 @@ function groupFilesIntoLibraryItemPaths(paths) {
// Step 3: Group files in dirs
var itemGroup = {}
pathsFiltered.forEach((path) => {
- var dirparts = Path.dirname(path).split('/')
+ var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory
var numparts = dirparts.length
var _path = ''
- // Iterate over directories in path
- for (let i = 0; i < numparts; i++) {
- var dirpart = dirparts.shift()
- _path = Path.posix.join(_path, dirpart)
+ if (!numparts) {
+ // Media file in root
+ itemGroup[path] = path
+ } else {
+ // Iterate over directories in path
+ for (let i = 0; i < numparts; i++) {
+ var dirpart = dirparts.shift()
+ _path = Path.posix.join(_path, dirpart)
- if (itemGroup[_path]) { // Directory already has files, add file
- var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
- itemGroup[_path].push(relpath)
- return
- } else if (!dirparts.length) { // This is the last directory, create group
- itemGroup[_path] = [Path.basename(path)]
- return
- } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
- itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
- return
+ if (itemGroup[_path]) { // Directory already has files, add file
+ var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
+ itemGroup[_path].push(relpath)
+ return
+ } else if (!dirparts.length) { // This is the last directory, create group
+ itemGroup[_path] = [Path.basename(path)]
+ return
+ } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
+ itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
+ return
+ }
}
}
})
@@ -62,9 +70,9 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
// Input: array of relative file items (see recurseFiles)
// Output: map of files grouped into potential libarary item dirs
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
- // Step 1: Filter out non-media files in root dir (with depth of 0)
+ // Step 1: Filter out non-book-media files in root dir (with depth of 0)
var itemsFiltered = fileItems.filter(i => {
- return i.deep > 0 || isMediaFile(mediaType, i.extension)
+ return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension))
})
// Step 2: Seperate media files and other files
@@ -128,7 +136,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
}
function cleanFileObjects(libraryItemPath, files) {
- return Promise.all(files.map(async (file) => {
+ return Promise.all(files.map(async(file) => {
var filePath = Path.posix.join(libraryItemPath, file)
var newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(filePath, file)
@@ -147,16 +155,6 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
}
var fileItems = await recurseFiles(folderPath)
- var basePath = folderPath
-
- const isOpenAudibleFolder = fileItems.find(fi => fi.deep === 0 && fi.name === 'books.json')
- if (isOpenAudibleFolder) {
- Logger.info(`[scandir] Detected Open Audible Folder, looking in books folder`)
- basePath = Path.posix.join(folderPath, 'books')
- fileItems = await recurseFiles(basePath)
- Logger.debug(`[scandir] ${fileItems.length} files found in books folder`)
- }
-
var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems)
if (!Object.keys(libraryItemGrouping).length) {
@@ -175,10 +173,10 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
mediaMetadata: {
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
},
- path: Path.posix.join(basePath, libraryItemPath),
+ path: Path.posix.join(folderPath, libraryItemPath),
relPath: libraryItemPath
}
- fileObjs = await cleanFileObjects(basePath, [libraryItemPath])
+ fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
isFile = true
} else {
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
@@ -211,83 +209,16 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
relPath = relPath.replace(/\\/g, '/')
var splitDir = relPath.split('/')
- // Audio files will always be in the directory named for the title
- var [title, narrators] = getTitleAndNarrator(splitDir.pop())
+ var folder = splitDir.pop() // Audio files will always be in the directory named for the title
+ series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
+ author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
- var series = null
- var author = null
- // If there are at least 2 more directories, next furthest will be the series
- if (splitDir.length > 1) series = splitDir.pop()
- if (splitDir.length > 0) author = splitDir.pop()
- // There could be many more directories, but only the top 3 are used for naming /author/series/title/
-
-
- // If in a series directory check for volume number match
- /* ACCEPTS
- Book 2 - Title Here - Subtitle Here
- Title Here - Subtitle Here - Vol 12
- Title Here - volume 9 - Subtitle Here
- Vol. 3 Title Here - Subtitle Here
- 1980 - Book 2-Title Here
- Title Here-Volume 999-Subtitle Here
- 2 - Book Title
- 100 - Book Title
- 0.5 - Book Title
- */
- var volumeNumber = null
- if (series) {
- // Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number
- var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./)
- if (volumeMatch && volumeMatch.length > 1) {
- volumeNumber = volumeMatch[1]
- title = title.replace(`${volumeNumber} - `, '')
- } else {
- // Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
- var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
- if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
- volumeNumber = volumeMatch[3]
- var replaceChunk = volumeMatch[2]
-
- // "1980 - Book 2-Title Here"
- // Group 1 would be "- "
- // Group 3 would be "-"
- // Only remove the first group
- if (volumeMatch[1]) {
- replaceChunk = volumeMatch[1] + replaceChunk
- } else if (volumeMatch[4]) {
- replaceChunk += volumeMatch[4]
- }
- title = title.replace(replaceChunk, '').trim()
- }
- }
-
- if (volumeNumber != null && !isNaN(volumeNumber)) {
- volumeNumber = String(Number(volumeNumber)) // Strips leading zeros
- }
- }
-
- var publishedYear = null
- // If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year
- var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/)
- if (publishYearMatch && publishYearMatch.length > 2 && publishYearMatch[1]) {
- // Strip parentheses
- if (publishYearMatch[1].startsWith('(') && publishYearMatch[1].endsWith(')')) {
- publishYearMatch[1] = publishYearMatch[1].slice(1, -1)
- }
- if (!isNaN(publishYearMatch[1])) {
- publishedYear = publishYearMatch[1]
- title = publishYearMatch[2]
- }
- }
-
- // Subtitle can be parsed from the title if user enabled
- // Subtitle is everything after " - "
- var subtitle = null
- if (parseSubtitle && title.includes(' - ')) {
- var splitOnSubtitle = title.split(' - ')
- title = splitOnSubtitle.shift()
- subtitle = splitOnSubtitle.join(' - ')
- }
+ // The may contain various other pieces of metadata, these functions extract it.
+ var [folder, narrators] = getNarrator(folder)
+ if (series) { var [folder, sequence] = getSequence(folder) }
+ var [folder, sequence] = series ? getSequence(folder) : [folder, null]
+ var [folder, publishedYear] = getPublishedYear(folder)
+ var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
return {
mediaMetadata: {
@@ -295,7 +226,7 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
title,
subtitle,
series,
- sequence: volumeNumber,
+ sequence,
publishedYear,
narrators,
},
@@ -304,10 +235,65 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
}
}
-function getTitleAndNarrator(folder) {
- let pattern = /^(?.*)\{(?.*)\} *$/
+function getNarrator(folder) {
+ let pattern = /^(?.*) \{(?.*)\}$/
let match = folder.match(pattern)
- return match ? [match.groups.title.trimEnd(), match.groups.narrators] : [folder, null]
+ return match ? [match.groups.title, match.groups.narrators] : [folder, null]
+}
+
+function getSequence(folder) {
+ // Valid ways of including a volume number:
+ // [
+ // 'Book 2 - Title - Subtitle',
+ // 'Title - Subtitle - Vol 12',
+ // 'Title - volume 9 - Subtitle',
+ // 'Vol. 3 Title Here - Subtitle',
+ // '1980 - Book 2 - Title',
+ // 'Volume 12. Title - Subtitle',
+ // '100 - Book Title',
+ // '2 - Book Title',
+ // '6. Title',
+ // '0.5 - Book Title'
+ // ]
+
+ // Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
+ let pattern = /^(?vol\.? |volume |book )?(?\d{1,3}(?:\.\d{1,2})?)(?\.?)(?: (?.*))?/i
+
+ let volumeNumber = null
+ let parts = folder.split(' - ')
+ for (let i = 0; i < parts.length; i++) {
+ let match = parts[i].match(pattern)
+
+ // This excludes '101 Dalmations' but includes '101. Dalmations'
+ if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
+ volumeNumber = match.groups.sequence
+ parts[i] = match.groups.suffix
+ if (!parts[i]) { parts.splice(i, 1) }
+ break
+ }
+ }
+
+ folder = parts.join(' - ')
+ return [folder, volumeNumber]
+}
+
+function getPublishedYear(folder) {
+ var publishedYear = null
+
+ pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
+ var match = folder.match(pattern)
+ if (match) {
+ publishedYear = match[1]
+ folder = match[2]
+ }
+
+ return [folder, publishedYear]
+}
+
+function getSubtitle(folder) {
+ // Subtitle is everything after " - "
+ var splitTitle = folder.split(' - ')
+ return [splitTitle.shift(), splitTitle.join(' - ')]
}
function getPodcastDataFromDir(folderPath, relPath) {
@@ -335,14 +321,34 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin
}
// Called from Scanner.js
-async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) {
- var fileItems = await recurseFiles(libraryItemPath)
-
+async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) {
libraryItemPath = libraryItemPath.replace(/\\/g, '/')
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1)
- var libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings)
+ var libraryItemData = {}
+
+ var fileItems = []
+
+ if (isSingleMediaItem) { // Single media item in root of folder
+ fileItems = [
+ {
+ fullpath: libraryItemPath,
+ path: libraryItemDir // actually the relPath (only filename here)
+ }
+ ]
+ libraryItemData = {
+ path: libraryItemPath, // full path
+ relPath: libraryItemDir, // only filename
+ mediaMetadata: {
+ title: Path.basename(libraryItemDir, Path.extname(libraryItemDir))
+ }
+ }
+ } else {
+ fileItems = await recurseFiles(libraryItemPath)
+ libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings)
+ }
+
var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)
var libraryItem = {
ino: libraryItemDirStats.ino,
@@ -353,6 +359,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
libraryId: folder.libraryId,
path: libraryItemData.path,
relPath: libraryItemData.relPath,
+ isFile: isSingleMediaItem,
media: {
metadata: libraryItemData.mediaMetadata || null
},