diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue new file mode 100644 index 00000000..8f3ab9dc --- /dev/null +++ b/client/components/modals/ShareModal.vue @@ -0,0 +1,195 @@ + + + + + Share media item + + + + + + Share URL + + + + Expires in {{ currentShareTimeRemaining }} + Permanent + + + + + + Slug + + + + + Share Duration + + + + + + + + + + + + + + Share URL will be: {{ demoShareUrl }} + + + + + {{ $strings.ButtonDelete }} + {{ $strings.ButtonShare }} + + + + + + diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 86fb4558..8267e6c6 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -147,6 +147,7 @@ + @@ -160,7 +161,7 @@ export default { } // Include episode downloads for podcasts - var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => { + var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => { console.error('Failed', error) return false }) @@ -170,7 +171,8 @@ export default { } return { libraryItem: item, - rssFeed: item.rssFeed || null + rssFeed: item.rssFeed || null, + mediaItemShare: item.mediaItemShare || null } }, data() { @@ -184,7 +186,8 @@ export default { episodeDownloadsQueued: [], showBookmarksModal: false, isDescriptionClamped: false, - showFullDescription: false + showFullDescription: false, + showShareModal: false } }, computed: { @@ -437,6 +440,13 @@ export default { }) } + if (this.userIsAdminOrUp && !this.isPodcast) { + items.push({ + text: 'Share', + action: 'share' + }) + } + if (this.userCanDelete) { items.push({ text: this.$strings.ButtonDelete, @@ -448,6 +458,12 @@ export default { } }, methods: { + openedShare(mediaItemShare) { + this.mediaItemShare = mediaItemShare + }, + removedShare() { + this.mediaItemShare = null + }, selectBookmark(bookmark) { if (!bookmark) return if (this.isStreaming) { @@ -761,6 +777,8 @@ export default { this.deleteLibraryItem() } else if (action === 'sendToDevice') { this.sendToDevice(data) + } else if (action === 'share') { + this.showShareModal = true } } }, diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue new file mode 100644 index 00000000..42ed6460 --- /dev/null +++ b/client/pages/share/_slug.vue @@ -0,0 +1,34 @@ + + + + {{ mediaItemShare.mediaItem.title }} + + + + + diff --git a/client/plugins/utils.js b/client/plugins/utils.js index 495a14ef..ffcd33ad 100644 --- a/client/plugins/utils.js +++ b/client/plugins/utils.js @@ -2,7 +2,10 @@ import Vue from 'vue' import cronParser from 'cron-parser' import { nanoid } from 'nanoid' -Vue.prototype.$randomId = () => nanoid() +Vue.prototype.$randomId = (len = null) => { + if (len && !isNaN(len)) return nanoid(len) + return nanoid() +} Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { if (isNaN(bytes) || bytes == 0) { @@ -119,7 +122,7 @@ Vue.prototype.$parseCronExpression = (expression) => { value: '* * * * *' } ] - const patternMatch = commonPatterns.find(p => p.value === expression) + const patternMatch = commonPatterns.find((p) => p.value === expression) if (patternMatch) { return { description: patternMatch.text @@ -132,13 +135,17 @@ Vue.prototype.$parseCronExpression = (expression) => { if (pieces[2] !== '*' || pieces[3] !== '*') { return null } - if (pieces[4] !== '*' && pieces[4].split(',').some(p => isNaN(p))) { + if (pieces[4] !== '*' && pieces[4].split(',').some((p) => isNaN(p))) { return null } const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] var weekdayText = 'day' - if (pieces[4] !== '*') weekdayText = pieces[4].split(',').map(p => weekdays[p]).join(', ') + if (pieces[4] !== '*') + weekdayText = pieces[4] + .split(',') + .map((p) => weekdays[p]) + .join(', ') return { description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}` @@ -146,7 +153,7 @@ Vue.prototype.$parseCronExpression = (expression) => { } Vue.prototype.$getNextScheduledDate = (expression) => { - const interval = cronParser.parseExpression(expression); + const interval = cronParser.parseExpression(expression) return interval.next().toDate() } @@ -171,10 +178,8 @@ Vue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => { export function supplant(str, subs) { // source: http://crockford.com/javascript/remedial.html - return str.replace(/{([^{}]*)}/g, - function (a, b) { - var r = subs[b] - return typeof r === 'string' || typeof r === 'number' ? r : a - } - ) + return str.replace(/{([^{}]*)}/g, function (a, b) { + var r = subs[b] + return typeof r === 'string' || typeof r === 'number' ? r : a + }) } diff --git a/server/Database.js b/server/Database.js index 64dc518e..4732c960 100644 --- a/server/Database.js +++ b/server/Database.js @@ -142,7 +142,7 @@ class Database { * @returns {boolean} */ async checkHasDb() { - if (!await fs.pathExists(this.dbPath)) { + if (!(await fs.pathExists(this.dbPath))) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) return false } @@ -159,14 +159,13 @@ class Database { // First check if this is a new database this.isNew = !(await this.checkHasDb()) || force - if (!await this.connect()) { + if (!(await this.connect())) { throw new Error('Database connection failed') } await this.buildModels(force) Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', ')) - await this.loadData() } @@ -179,11 +178,11 @@ class Database { let logging = false let benchmark = false - if (process.env.QUERY_LOGGING === "log") { + if (process.env.QUERY_LOGGING === 'log') { // Setting QUERY_LOGGING=log will log all Sequelize queries before they run Logger.info(`[Database] Query logging enabled`) logging = (query) => Logger.debug(`Running the following query:\n ${query}`) - } else if (process.env.QUERY_LOGGING === "benchmark") { + } else if (process.env.QUERY_LOGGING === 'benchmark') { // Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run Logger.info(`[Database] Query benchmarking enabled"`) logging = (query, time) => Logger.debug(`Ran the following query in ${time}ms:\n ${query}`) @@ -199,7 +198,7 @@ class Database { }) // Helper function - this.sequelize.uppercaseFirst = str => str ? `${str[0].toUpperCase()}${str.substr(1)}` : '' + this.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') try { await this.sequelize.authenticate() @@ -250,30 +249,31 @@ class Database { require('./models/FeedEpisode').init(this.sequelize) require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) + require('./models/MediaItemShare').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } /** * Compare two server versions - * @param {string} v1 - * @param {string} v2 + * @param {string} v1 + * @param {string} v2 * @returns {-1|0|1} 1 if v1 > v2 */ compareVersions(v1, v2) { if (!v1 || !v2) return 0 - return v1.localeCompare(v2, undefined, { numeric: true, sensitivity: "case", caseFirst: "upper" }) + return v1.localeCompare(v2, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' }) } /** * Checks if migration to sqlite db is necessary & runs migration. - * + * * Check if version was upgraded and run any version specific migrations. - * + * * Loads most of the data from the database. This is a temporary solution. */ async loadData() { - if (this.isNew && await dbMigration.checkShouldMigrate()) { + if (this.isNew && (await dbMigration.checkShouldMigrate())) { Logger.info(`[Database] New database was created and old database was detected - migrating old to new`) await dbMigration.migrate(this.models) } @@ -323,9 +323,9 @@ class Database { /** * Create root user - * @param {string} username - * @param {string} pash - * @param {Auth} auth + * @param {string} username + * @param {string} pash + * @param {Auth} auth * @returns {boolean} true if created */ async createRootUser(username, pash, auth) { @@ -359,7 +359,7 @@ class Database { updateBulkUsers(oldUsers) { if (!this.sequelize) return false - return Promise.all(oldUsers.map(u => this.updateUser(u))) + return Promise.all(oldUsers.map((u) => this.updateUser(u))) } removeUser(userId) { @@ -379,7 +379,7 @@ class Database { updateBulkBooks(oldBooks) { if (!this.sequelize) return false - return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook))) + return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) } createLibrary(oldLibrary) { @@ -420,8 +420,8 @@ class Database { /** * Save metadata file and update library item - * - * @param {import('./objects/LibraryItem')} oldLibraryItem + * + * @param {import('./objects/LibraryItem')} oldLibraryItem * @returns {Promise} */ async updateLibraryItem(oldLibraryItem) { @@ -548,7 +548,7 @@ class Database { replaceTagInFilterData(oldTag, newTag) { for (const libraryId in this.libraryFilterData) { - const indexOf = this.libraryFilterData[libraryId].tags.findIndex(n => n === oldTag) + const indexOf = this.libraryFilterData[libraryId].tags.findIndex((n) => n === oldTag) if (indexOf >= 0) { this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag) } @@ -557,7 +557,7 @@ class Database { removeTagFromFilterData(tag) { for (const libraryId in this.libraryFilterData) { - this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag) + this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter((t) => t !== tag) } } @@ -572,7 +572,7 @@ class Database { replaceGenreInFilterData(oldGenre, newGenre) { for (const libraryId in this.libraryFilterData) { - const indexOf = this.libraryFilterData[libraryId].genres.findIndex(n => n === oldGenre) + const indexOf = this.libraryFilterData[libraryId].genres.findIndex((n) => n === oldGenre) if (indexOf >= 0) { this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre) } @@ -581,7 +581,7 @@ class Database { removeGenreFromFilterData(genre) { for (const libraryId in this.libraryFilterData) { - this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter(g => g !== genre) + this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter((g) => g !== genre) } } @@ -596,7 +596,7 @@ class Database { replaceNarratorInFilterData(oldNarrator, newNarrator) { for (const libraryId in this.libraryFilterData) { - const indexOf = this.libraryFilterData[libraryId].narrators.findIndex(n => n === oldNarrator) + const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator) if (indexOf >= 0) { this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator) } @@ -605,7 +605,7 @@ class Database { removeNarratorFromFilterData(narrator) { for (const libraryId in this.libraryFilterData) { - this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter(n => n !== narrator) + this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator) } } @@ -620,13 +620,13 @@ class Database { removeSeriesFromFilterData(libraryId, seriesId) { if (!this.libraryFilterData[libraryId]) return - this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId) + this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter((se) => se.id !== seriesId) } addSeriesToFilterData(libraryId, seriesName, seriesId) { if (!this.libraryFilterData[libraryId]) return // Check if series is already added - if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return + if (this.libraryFilterData[libraryId].series.some((se) => se.id === seriesId)) return this.libraryFilterData[libraryId].series.push({ id: seriesId, name: seriesName @@ -635,13 +635,13 @@ class Database { removeAuthorFromFilterData(libraryId, authorId) { if (!this.libraryFilterData[libraryId]) return - this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId) + this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter((au) => au.id !== authorId) } addAuthorToFilterData(libraryId, authorName, authorId) { if (!this.libraryFilterData[libraryId]) return // Check if author is already added - if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return + if (this.libraryFilterData[libraryId].authors.some((au) => au.id === authorId)) return this.libraryFilterData[libraryId].authors.push({ id: authorId, name: authorName @@ -662,63 +662,63 @@ class Database { * Used when updating items to make sure author id exists * If library filter data is set then use that for check * otherwise lookup in db - * @param {string} libraryId - * @param {string} authorId + * @param {string} libraryId + * @param {string} authorId * @returns {Promise} */ async checkAuthorExists(libraryId, authorId) { if (!this.libraryFilterData[libraryId]) { return this.authorModel.checkExistsById(authorId) } - return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId) + return this.libraryFilterData[libraryId].authors.some((au) => au.id === authorId) } /** * Used when updating items to make sure series id exists * If library filter data is set then use that for check * otherwise lookup in db - * @param {string} libraryId - * @param {string} seriesId + * @param {string} libraryId + * @param {string} seriesId * @returns {Promise} */ async checkSeriesExists(libraryId, seriesId) { if (!this.libraryFilterData[libraryId]) { return this.seriesModel.checkExistsById(seriesId) } - return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId) + return this.libraryFilterData[libraryId].series.some((se) => se.id === seriesId) } /** * Get author id for library by name. Uses library filter data if available - * - * @param {string} libraryId - * @param {string} authorName - * @returns {Promise} author id or null if not found + * + * @param {string} libraryId + * @param {string} authorName + * @returns {Promise} author id or null if not found */ async getAuthorIdByName(libraryId, authorName) { if (!this.libraryFilterData[libraryId]) { return (await this.authorModel.getOldByNameAndLibrary(authorName, libraryId))?.id || null } - return this.libraryFilterData[libraryId].authors.find(au => au.name === authorName)?.id || null + return this.libraryFilterData[libraryId].authors.find((au) => au.name === authorName)?.id || null } /** * Get series id for library by name. Uses library filter data if available - * - * @param {string} libraryId - * @param {string} seriesName + * + * @param {string} libraryId + * @param {string} seriesName * @returns {Promise} series id or null if not found */ async getSeriesIdByName(libraryId, seriesName) { if (!this.libraryFilterData[libraryId]) { return (await this.seriesModel.getOldByNameAndLibrary(seriesName, libraryId))?.id || null } - return this.libraryFilterData[libraryId].series.find(se => se.name === seriesName)?.id || null + return this.libraryFilterData[libraryId].series.find((se) => se.name === seriesName)?.id || null } /** * Reset numIssues for library - * @param {string} libraryId + * @param {string} libraryId */ async resetLibraryIssuesFilterData(libraryId) { if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set @@ -798,4 +798,4 @@ class Database { } } -module.exports = new Database() \ No newline at end of file +module.exports = new Database() diff --git a/server/Server.js b/server/Server.js index 404c1979..6428d0fc 100644 --- a/server/Server.js +++ b/server/Server.js @@ -20,6 +20,7 @@ const SocketAuthority = require('./SocketAuthority') const ApiRouter = require('./routers/ApiRouter') const HlsRouter = require('./routers/HlsRouter') +const PublicRouter = require('./routers/PublicRouter') const LogManager = require('./managers/LogManager') const NotificationManager = require('./managers/NotificationManager') @@ -34,6 +35,7 @@ const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') const ApiCacheManager = require('./managers/ApiCacheManager') const BinaryManager = require('./managers/BinaryManager') +const ShareManager = require('./managers/ShareManager') const LibraryScanner = require('./scanner/LibraryScanner') //Import the main Passport and Express-Session library @@ -79,6 +81,7 @@ class Server { // Routers this.apiRouter = new ApiRouter(this) this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager) + this.publicRouter = new PublicRouter() Logger.logManager = new LogManager() @@ -116,6 +119,7 @@ class Server { await this.cleanUserData() // Remove invalid user item progress await CacheManager.ensureCachePaths() + await ShareManager.init() await this.backupManager.init() await this.rssFeedManager.init() @@ -250,6 +254,7 @@ class Server { router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) + router.use('/public', this.publicRouter.router) // RSS Feed temp route router.get('/feed/:slug', (req, res) => { @@ -287,7 +292,8 @@ class Server { '/config/users/:id/sessions', '/config/item-metadata-utils/:id', '/collection/:id', - '/playlist/:id' + '/playlist/:id', + '/share/:slug' ] dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index e987dda0..32b098ed 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -13,18 +13,19 @@ const AudioFileScanner = require('../scanner/AudioFileScanner') const Scanner = require('../scanner/Scanner') const CacheManager = require('../managers/CacheManager') const CoverManager = require('../managers/CoverManager') +const ShareManager = require('../managers/ShareManager') class LibraryItemController { - constructor() { } + constructor() {} /** * GET: /api/items/:id * Optional query params: * ?include=progress,rssfeed,downloads * ?expanded=1 - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async findOne(req, res) { const includeEntities = (req.query.include || '').split(',') @@ -42,9 +43,13 @@ class LibraryItemController { item.rssFeed = feedData?.toJSONMinified() || null } + if (item.mediaType === 'book' && includeEntities.includes('share')) { + item.mediaItemShare = ShareManager.findByMediaItemId(item.media.id) + } + if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) { const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id) - item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient()) + item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient()) if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) { item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()] } @@ -88,9 +93,9 @@ class LibraryItemController { /** * GET: /api/items/:id/download * Download library item. Zip file if multiple files. - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ download(req, res) { if (!req.user.canDownload) { @@ -120,9 +125,9 @@ class LibraryItemController { /** * PATCH: /items/:id/media * Update media for a library item. Will create new authors & series when necessary - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async updateMedia(req, res) { const libraryItem = req.libraryItem @@ -151,8 +156,8 @@ class LibraryItemController { // Book specific - Get all series being removed from this item let seriesRemoved = [] if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || [] - seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id)) + const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] + seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) } const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url @@ -162,7 +167,10 @@ class LibraryItemController { if (seriesRemoved.length) { // Check remove empty series Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id)) + await this.checkRemoveEmptySeries( + libraryItem.media.id, + seriesRemoved.map((se) => se.id) + ) } if (isPodcastAutoDownloadUpdated) { @@ -252,12 +260,14 @@ class LibraryItemController { /** * GET: api/items/:id/cover - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getCover(req, res) { - const { query: { width, height, format, raw } } = req + const { + query: { width, height, format, raw } + } = req const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, { attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], @@ -283,14 +293,14 @@ class LibraryItemController { } // Check if library item media has a cover path - if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) { + if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) { return res.sendStatus(404) } - if (req.query.ts) - res.set('Cache-Control', 'private, max-age=86400') + if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') - if (raw) { // any value + if (raw) { + // any value if (global.XAccel) { const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) @@ -325,7 +335,7 @@ class LibraryItemController { return res.sendStatus(404) } var episodeId = req.params.episodeId - if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) { + if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) { Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`) return res.sendStatus(404) } @@ -412,8 +422,8 @@ class LibraryItemController { let seriesRemoved = [] if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id) - seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id)) + const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) + seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) } if (libraryItem.media.update(mediaPayload)) { @@ -422,7 +432,10 @@ class LibraryItemController { if (seriesRemoved.length) { // Check remove empty series Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id)) + await this.checkRemoveEmptySeries( + libraryItem.media.id, + seriesRemoved.map((se) => se.id) + ) } await Database.updateLibraryItem(libraryItem) @@ -447,7 +460,7 @@ class LibraryItemController { id: libraryItemIds }) res.json({ - libraryItems: libraryItems.map(li => li.toJSONExpanded()) + libraryItems: libraryItems.map((li) => li.toJSONExpanded()) }) } @@ -542,7 +555,7 @@ class LibraryItemController { const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id) await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) res.json({ - result: Object.keys(ScanResult).find(key => ScanResult[key] == result) + result: Object.keys(ScanResult).find((key) => ScanResult[key] == result) }) } @@ -593,9 +606,9 @@ class LibraryItemController { /** * GET api/items/:id/ffprobe/:fileid * FFProbe JSON result from audio file - * + * * @param {express.Request} req - * @param {express.Response} res + * @param {express.Response} res */ async getFFprobeData(req, res) { if (!req.user.isAdminOrUp) { @@ -619,9 +632,9 @@ class LibraryItemController { /** * GET api/items/:id/file/:fileid - * + * * @param {express.Request} req - * @param {express.Response} res + * @param {express.Response} res */ async getLibraryFile(req, res) { const libraryFile = req.libraryFile @@ -642,9 +655,9 @@ class LibraryItemController { /** * DELETE api/items/:id/file/:fileid - * + * * @param {express.Request} req - * @param {express.Response} res + * @param {express.Response} res */ async deleteLibraryFile(req, res) { const libraryFile = req.libraryFile @@ -672,7 +685,7 @@ class LibraryItemController { * GET api/items/:id/file/:fileid/download * Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads * @param {express.Request} req - * @param {express.Response} res + * @param {express.Response} res */ async downloadLibraryFile(req, res) { const libraryFile = req.libraryFile @@ -704,14 +717,14 @@ class LibraryItemController { * fileid is the inode value stored in LibraryFile.ino or EBookFile.ino * fileid is only required when reading a supplementary ebook * when no fileid is passed in the primary ebook will be returned - * + * * @param {express.Request} req - * @param {express.Response} res + * @param {express.Response} res */ async getEBookFile(req, res) { let ebookFile = null if (req.params.fileid) { - ebookFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid) + ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) if (!ebookFile?.isEBookFile) { Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) return res.status(400).send('Invalid ebook file id') @@ -740,12 +753,12 @@ class LibraryItemController { * toggle the status of an ebook file. * if an ebook file is the primary ebook, then it will be changed to supplementary * if an ebook file is supplementary, then it will be changed to primary - * + * * @param {express.Request} req - * @param {express.Response} res + * @param {express.Response} res */ async updateEbookFileStatus(req, res) { - const ebookLibraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid) + const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) if (!ebookLibraryFile?.isEBookFile) { Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) return res.status(400).send('Invalid ebook file id') @@ -777,7 +790,7 @@ class LibraryItemController { // For library file routes, get the library file if (req.params.fileid) { - req.libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid) + req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) if (!req.libraryFile) { Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`) return res.sendStatus(404) @@ -797,4 +810,4 @@ class LibraryItemController { next() } } -module.exports = new LibraryItemController() \ No newline at end of file +module.exports = new LibraryItemController() diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js new file mode 100644 index 00000000..ca201f94 --- /dev/null +++ b/server/controllers/ShareController.js @@ -0,0 +1,137 @@ +const Logger = require('../Logger') +const Database = require('../Database') +const { Op } = require('sequelize') + +const ShareManager = require('../managers/ShareManager') + +class ShareController { + constructor() {} + + /** + * Public route + * GET: /api/share/mediaitem/:slug + * Get media item share by slug + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getMediaItemShareBySlug(req, res) { + const { slug } = req.params + + const mediaItemShare = ShareManager.findBySlug(slug) + if (!mediaItemShare) { + return res.status(404) + } + if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) { + ShareManager.removeMediaItemShare(mediaItemShare.id) + return res.status(404).send('Media item share not found') + } + + try { + const mediaItemModel = mediaItemShare.mediaItemType === 'book' ? Database.bookModel : Database.podcastEpisodeModel + mediaItemShare.mediaItem = await mediaItemModel.findByPk(mediaItemShare.mediaItemId) + + if (!mediaItemShare.mediaItem) { + return res.status(404).send('Media item not found') + } + res.json(mediaItemShare) + } catch (error) { + Logger.error(`[ShareController] Failed`, error) + res.status(500).send('Internal server error') + } + } + + /** + * POST: /api/share/mediaitem + * Create a new media item share + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async createMediaItemShare(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[ShareController] Non-admin user "${req.user.username}" attempted to create item share`) + return res.sendStatus(403) + } + + const { slug, expiresAt, mediaItemType, mediaItemId } = req.body + + if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') { + return res.status(400).send('Missing or invalid required fields') + } + if (expiresAt === null || isNaN(expiresAt) || expiresAt < 0) { + return res.status(400).send('Invalid expiration date') + } + if (!['book', 'podcastEpisode'].includes(mediaItemType)) { + return res.status(400).send('Invalid media item type') + } + + try { + // Check if the media item share already exists by slug or mediaItemId + const existingMediaItemShare = await Database.models.mediaItemShare.findOne({ + where: { + [Op.or]: [{ slug }, { mediaItemId }] + } + }) + if (existingMediaItemShare) { + if (existingMediaItemShare.mediaItemId === mediaItemId) { + return res.status(409).send('Item is already shared') + } else { + return res.status(409).send('Slug is already in use') + } + } + + // Check that media item exists + const mediaItemModel = mediaItemType === 'book' ? Database.bookModel : Database.podcastEpisodeModel + const mediaItem = await mediaItemModel.findByPk(mediaItemId) + if (!mediaItem) { + return res.status(404).send('Media item not found') + } + + const mediaItemShare = await Database.models.mediaItemShare.create({ + slug, + expiresAt: expiresAt || null, + mediaItemId, + mediaItemType, + userId: req.user.id + }) + + ShareManager.openMediaItemShare(mediaItemShare) + + res.status(201).json(mediaItemShare?.toJSONForClient()) + } catch (error) { + Logger.error(`[ShareController] Failed`, error) + res.status(500).send('Internal server error') + } + } + + /** + * DELETE: /api/share/mediaitem/:id + * Delete media item share + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async deleteMediaItemShare(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[ShareController] Non-admin user "${req.user.username}" attempted to delete item share`) + return res.sendStatus(403) + } + + try { + const mediaItemShare = await Database.models.mediaItemShare.findByPk(req.params.id) + if (!mediaItemShare) { + return res.status(404).send('Media item share not found') + } + + ShareManager.removeMediaItemShare(mediaItemShare.id) + + await mediaItemShare.destroy() + res.sendStatus(204) + } catch (error) { + Logger.error(`[ShareController] Failed`, error) + res.status(500).send('Internal server error') + } + } +} +module.exports = new ShareController() diff --git a/server/managers/ShareManager.js b/server/managers/ShareManager.js new file mode 100644 index 00000000..930329c8 --- /dev/null +++ b/server/managers/ShareManager.js @@ -0,0 +1,137 @@ +const Database = require('../Database') +const Logger = require('../Logger') + +/** + * @typedef OpenMediaItemShareObject + * @property {string} id + * @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare + * @property {NodeJS.Timeout} timeout + */ + +class ShareManager { + constructor() { + /** @type {OpenMediaItemShareObject[]} */ + this.openMediaItemShares = [] + } + + init() { + this.loadMediaItemShares() + } + + /** + * Find an open media item share by media item ID + * @param {string} mediaItemId + * @returns {import('../models/MediaItemShare').MediaItemShareForClient} + */ + findByMediaItemId(mediaItemId) { + const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.mediaItemId === mediaItemId)?.mediaItemShare + if (mediaItemShareObject) { + const mediaItemShareObjectForClient = { ...mediaItemShareObject } + delete mediaItemShareObjectForClient.pash + delete mediaItemShareObjectForClient.userId + delete mediaItemShareObjectForClient.extraData + return mediaItemShareObjectForClient + } + return null + } + + /** + * Find an open media item share by slug + * @param {string} slug + * @returns {import('../models/MediaItemShare').MediaItemShareForClient} + */ + findBySlug(slug) { + const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.slug === slug)?.mediaItemShare + if (mediaItemShareObject) { + const mediaItemShareObjectForClient = { ...mediaItemShareObject } + delete mediaItemShareObjectForClient.pash + delete mediaItemShareObjectForClient.userId + delete mediaItemShareObjectForClient.extraData + return mediaItemShareObjectForClient + } + return null + } + + /** + * Load all media item shares from the database + * Remove expired & schedule active + */ + async loadMediaItemShares() { + /** @type {import('../models/MediaItemShare').MediaItemShareModel[]} */ + const mediaItemShares = await Database.models.mediaItemShare.findAll() + + for (const mediaItemShare of mediaItemShares) { + if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) { + Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`) + await this.destroyMediaItemShare(mediaItemShare.id) + } else if (mediaItemShare.expiresAt) { + this.scheduleMediaItemShare(mediaItemShare) + } else { + Logger.info(`[ShareManager] Loaded permanent media item share "${mediaItemShare.id}"`) + this.openMediaItemShares.push({ + id: mediaItemShare.id, + mediaItemShare: mediaItemShare.toJSON() + }) + } + } + } + + /** + * + * @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare + */ + scheduleMediaItemShare(mediaItemShare) { + if (!mediaItemShare?.expiresAt) return + + const expiresAtDuration = mediaItemShare.expiresAt.valueOf() - Date.now() + if (expiresAtDuration <= 0) { + Logger.warn(`[ShareManager] Attempted to schedule expired media item share "${mediaItemShare.id}"`) + this.destroyMediaItemShare(mediaItemShare.id) + return + } + + const timeout = setTimeout(() => { + Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`) + this.removeMediaItemShare(mediaItemShare.id) + }, expiresAtDuration) + this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON(), timeout }) + Logger.info(`[ShareManager] Scheduled media item share "${mediaItemShare.id}" to expire in ${expiresAtDuration}ms`) + } + + /** + * + * @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare + */ + openMediaItemShare(mediaItemShare) { + if (mediaItemShare.expiresAt) { + this.scheduleMediaItemShare(mediaItemShare) + } else { + this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON() }) + } + } + + /** + * + * @param {string} mediaItemShareId + */ + async removeMediaItemShare(mediaItemShareId) { + const mediaItemShare = this.openMediaItemShares.find((s) => s.id === mediaItemShareId) + if (!mediaItemShare) return + + if (mediaItemShare.timeout) { + clearTimeout(mediaItemShare.timeout) + } + + this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId) + await this.destroyMediaItemShare(mediaItemShareId) + } + + /** + * + * @param {string} mediaItemShareId + */ + destroyMediaItemShare(mediaItemShareId) { + return Database.models.mediaItemShare.destroy({ where: { id: mediaItemShareId } }) + } +} +module.exports = new ShareManager() diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js new file mode 100644 index 00000000..74ec063e --- /dev/null +++ b/server/models/MediaItemShare.js @@ -0,0 +1,130 @@ +const { DataTypes, Model } = require('sequelize') + +/** + * @typedef MediaItemShareObject + * @property {UUIDV4} id + * @property {UUIDV4} mediaItemId + * @property {string} mediaItemType + * @property {string} slug + * @property {string} pash + * @property {UUIDV4} userId + * @property {Date} expiresAt + * @property {Object} extraData + * @property {Date} createdAt + * @property {Date} updatedAt + * + * @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel + */ + +/** + * @typedef MediaItemShareForClient + * @property {UUIDV4} id + * @property {UUIDV4} mediaItemId + * @property {string} mediaItemType + * @property {string} slug + * @property {Date} expiresAt + * @property {Date} createdAt + * @property {Date} updatedAt + */ + +class MediaItemShare extends Model { + constructor(values, options) { + super(values, options) + } + + toJSONForClient() { + return { + id: this.id, + mediaItemId: this.mediaItemId, + mediaItemType: this.mediaItemType, + slug: this.slug, + expiresAt: this.expiresAt, + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + } + + /** + * + * @param {import('sequelize').FindOptions} options + * @returns {Promise} + */ + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } + + /** + * Initialize model + * + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + mediaItemId: DataTypes.UUIDV4, + mediaItemType: DataTypes.STRING, + slug: DataTypes.STRING, + pash: DataTypes.STRING, + expiresAt: DataTypes.DATE, + extraData: DataTypes.JSON + }, + { + sequelize, + modelName: 'mediaItemShare' + } + ) + + const { user, book, podcastEpisode } = sequelize.models + + user.hasMany(MediaItemShare) + MediaItemShare.belongsTo(user) + + book.hasMany(MediaItemShare, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'book' + } + }) + MediaItemShare.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) + + podcastEpisode.hasOne(MediaItemShare, { + foreignKey: 'mediaItemId', + constraints: false, + scope: { + mediaItemType: 'podcastEpisode' + } + }) + MediaItemShare.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) + + MediaItemShare.addHook('afterFind', (findResult) => { + if (!findResult) return + + if (!Array.isArray(findResult)) findResult = [findResult] + + for (const instance of findResult) { + if (instance.mediaItemType === 'book' && instance.book !== undefined) { + instance.mediaItem = instance.book + instance.dataValues.mediaItem = instance.dataValues.book + } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { + instance.mediaItem = instance.podcastEpisode + instance.dataValues.mediaItem = instance.dataValues.podcastEpisode + } + // To prevent mistakes: + delete instance.book + delete instance.dataValues.book + delete instance.podcastEpisode + delete instance.dataValues.podcastEpisode + } + }) + } +} + +module.exports = MediaItemShare diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index d1a5d5ab..be4953ee 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -30,6 +30,7 @@ const ToolsController = require('../controllers/ToolsController') const RSSFeedController = require('../controllers/RSSFeedController') const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') const MiscController = require('../controllers/MiscController') +const ShareController = require('../controllers/ShareController') const Author = require('../objects/entities/Author') const Series = require('../objects/entities/Series') @@ -310,6 +311,12 @@ class ApiRouter { this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this)) this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this)) + // + // Share routes + // + this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this)) + this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this)) + // // Misc Routes // diff --git a/server/routers/PublicRouter.js b/server/routers/PublicRouter.js new file mode 100644 index 00000000..623265b3 --- /dev/null +++ b/server/routers/PublicRouter.js @@ -0,0 +1,15 @@ +const express = require('express') +const ShareController = require('../controllers/ShareController') + +class PublicRouter { + constructor() { + this.router = express() + this.router.disable('x-powered-by') + this.init() + } + + init() { + this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this)) + } +} +module.exports = PublicRouter
Share media item
Expires in {{ currentShareTimeRemaining }}
Permanent
+ Share URL will be: {{ demoShareUrl }} +
{{ mediaItemShare.mediaItem.title }}