From 4cdc2a8c28c3759cdbbd84dd4ae2ac79f6bb7ee9 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen <31518305+glorenzen@users.noreply.github.com> Date: Sun, 29 Dec 2024 14:52:57 -0800 Subject: [PATCH] Feat/download via share link (#3666) * Adds share download endpoint * Adds Downloadable toggle to share modal --------- Co-authored-by: advplyr --- client/components/modals/ShareModal.vue | 20 ++++-- client/pages/share/_slug.vue | 10 +++ client/strings/en-us.json | 2 + server/controllers/ShareController.js | 65 +++++++++++++++++- server/migrations/changelog.md | 1 + .../v2.17.6-share-add-isdownloadable.js | 68 +++++++++++++++++++ server/models/MediaItemShare.js | 36 +++++++++- server/routers/PublicRouter.js | 1 + .../v2.17.6-share-add-isdownloadable.test.js | 68 +++++++++++++++++++ 9 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 server/migrations/v2.17.6-share-add-isdownloadable.js create mode 100644 test/server/migrations/v2.17.6-share-add-isdownloadable.test.js diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue index d0487fd386..5b37988476 100644 --- a/client/components/modals/ShareModal.vue +++ b/client/components/modals/ShareModal.vue @@ -19,12 +19,13 @@
-

{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}

+

{{ $strings.LabelDownloadable }}

+

{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}

{{ $strings.LabelPermanent }}

@@ -81,7 +91,8 @@ export default { text: this.$strings.LabelDays, value: 'days' } - ] + ], + isDownloadable: false } }, watch: { @@ -172,7 +183,8 @@ export default { slug: this.newShareSlug, mediaItemType: 'book', mediaItemId: this.libraryItem.media.id, - expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0 + expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0, + isDownloadable: this.isDownloadable } this.processing = true this.$axios diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 7ddb994c9c..6bce2f8aa3 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -12,6 +12,10 @@
+ + + + @@ -63,6 +67,9 @@ export default { if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg` return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover` }, + downloadUrl() { + return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download` + }, audioTracks() { return (this.playbackSession.audioTracks || []).map((track) => { track.relativeContentUrl = track.contentUrl @@ -247,6 +254,9 @@ export default { }, playerFinished() { console.log('Player finished') + }, + downloadShareItem() { + this.$downloadFile(this.downloadUrl) } }, mounted() { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index a029fadb95..eee94abf8c 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -300,6 +300,7 @@ "LabelDiscover": "Discover", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadable": "Downloadable", "LabelDuration": "Duration", "LabelDurationComparisonExactMatch": "(exact match)", "LabelDurationComparisonLonger": "({0} longer)", @@ -588,6 +589,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShare": "Share", + "LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.", "LabelShareOpen": "Share Open", "LabelShareURL": "Share URL", "LabelShowAll": "Show All", diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js index e1568c0dbe..93c6e9fbcf 100644 --- a/server/controllers/ShareController.js +++ b/server/controllers/ShareController.js @@ -7,6 +7,7 @@ const Database = require('../Database') const { PlayMethod } = require('../utils/constants') const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') +const zipHelpers = require('../utils/zipHelpers') const PlaybackSession = require('../objects/PlaybackSession') const ShareManager = require('../managers/ShareManager') @@ -210,6 +211,65 @@ class ShareController { res.sendFile(audioTrackPath) } + /** + * Public route - requires share_session_id cookie + * + * GET: /api/share/:slug/download + * Downloads media item share + * + * @param {Request} req + * @param {Response} res + */ + async downloadMediaItemShare(req, res) { + if (!req.cookies.share_session_id) { + return res.status(404).send('Share session not set') + } + + const { slug } = req.params + const mediaItemShare = ShareManager.findBySlug(slug) + if (!mediaItemShare) { + return res.status(404) + } + if (!mediaItemShare.isDownloadable) { + return res.status(403).send('Download is not allowed for this item') + } + + const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id) + if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) { + return res.status(404).send('Share session not found') + } + + const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, { + attributes: ['id', 'path', 'relPath', 'isFile'] + }) + if (!libraryItem) { + return res.status(404).send('Library item not found') + } + + const itemPath = libraryItem.path + const itemTitle = playbackSession.displayTitle + + Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`) + + try { + if (libraryItem.isFile) { + const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath)) + if (audioMimeType) { + res.setHeader('Content-Type', audioMimeType) + } + await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve()))) + } else { + const filename = `${itemTitle}.zip` + await zipHelpers.zipDirectoryPipe(itemPath, filename, res) + } + + Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`) + } catch (error) { + Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error) + res.status(500).send('Failed to download the item') + } + } + /** * Public route - requires share_session_id cookie * @@ -259,7 +319,7 @@ class ShareController { return res.sendStatus(403) } - const { slug, expiresAt, mediaItemType, mediaItemId } = req.body + const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') { return res.status(400).send('Missing or invalid required fields') @@ -298,7 +358,8 @@ class ShareController { expiresAt: expiresAt || null, mediaItemId, mediaItemType, - userId: req.user.id + userId: req.user.id, + isDownloadable }) ShareManager.openMediaItemShare(mediaItemShare) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index f49924327a..c2de4693b8 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -11,3 +11,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | | v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | | v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables | +| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table | diff --git a/server/migrations/v2.17.6-share-add-isdownloadable.js b/server/migrations/v2.17.6-share-add-isdownloadable.js new file mode 100644 index 0000000000..9434d28449 --- /dev/null +++ b/server/migrations/v2.17.6-share-add-isdownloadable.js @@ -0,0 +1,68 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.17.6' +const migrationName = `${migrationVersion}-share-add-isdownloadable` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration script adds the isDownloadable column to the mediaItemShares table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('mediaItemShares')) { + const tableDescription = await queryInterface.describeTable('mediaItemShares') + if (!tableDescription.isDownloadable) { + logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`) + await queryInterface.addColumn('mediaItemShares', 'isDownloadable', { + type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`) + } else { + logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`) + } + } else { + logger.info(`${loggerPrefix} mediaItemShares table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the isDownloadable column from the mediaItemShares table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('mediaItemShares')) { + const tableDescription = await queryInterface.describeTable('mediaItemShares') + if (tableDescription.isDownloadable) { + logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`) + await queryInterface.removeColumn('mediaItemShares', 'isDownloadable') + logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`) + } else { + logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`) + } + } else { + logger.info(`${loggerPrefix} mediaItemShares table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index 38b8dbbf4b..2d7b3896a7 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize') * @property {Object} extraData * @property {Date} createdAt * @property {Date} updatedAt + * @property {boolean} isDownloadable * * @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel */ @@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize') * @property {Date} expiresAt * @property {Date} createdAt * @property {Date} updatedAt + * @property {boolean} isDownloadable */ class MediaItemShare extends Model { constructor(values, options) { super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.mediaItemId + /** @type {string} */ + this.mediaItemType + /** @type {string} */ + this.slug + /** @type {string} */ + this.pash + /** @type {UUIDV4} */ + this.userId + /** @type {Date} */ + this.expiresAt + /** @type {Object} */ + this.extraData + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + /** @type {boolean} */ + this.isDownloadable + + // Expanded properties + + /** @type {import('./Book')|import('./PodcastEpisode')} */ + this.mediaItem } toJSONForClient() { @@ -40,7 +70,8 @@ class MediaItemShare extends Model { slug: this.slug, expiresAt: this.expiresAt, createdAt: this.createdAt, - updatedAt: this.updatedAt + updatedAt: this.updatedAt, + isDownloadable: this.isDownloadable } } @@ -114,7 +145,8 @@ class MediaItemShare extends Model { slug: DataTypes.STRING, pash: DataTypes.STRING, expiresAt: DataTypes.DATE, - extraData: DataTypes.JSON + extraData: DataTypes.JSON, + isDownloadable: DataTypes.BOOLEAN }, { sequelize, diff --git a/server/routers/PublicRouter.js b/server/routers/PublicRouter.js index 98ac4955a6..107edf99cd 100644 --- a/server/routers/PublicRouter.js +++ b/server/routers/PublicRouter.js @@ -15,6 +15,7 @@ class PublicRouter { this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this)) this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this)) this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this)) + this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this)) this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this)) } } diff --git a/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js new file mode 100644 index 0000000000..6c778b1487 --- /dev/null +++ b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js @@ -0,0 +1,68 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes } = require('sequelize') + +const { up, down } = require('../../../server/migrations/v2.17.6-share-add-isdownloadable') + +describe('Migration v2.17.6-share-add-isDownloadable', () => { + let queryInterface, logger + + beforeEach(() => { + queryInterface = { + addColumn: sinon.stub().resolves(), + removeColumn: sinon.stub().resolves(), + tableExists: sinon.stub().resolves(true), + describeTable: sinon.stub().resolves({ isDownloadable: undefined }), + sequelize: { + Sequelize: { + DataTypes: { + BOOLEAN: DataTypes.BOOLEAN + } + } + } + } + + logger = { + info: sinon.stub(), + error: sinon.stub() + } + }) + + describe('up', () => { + it('should add the isDownloadable column to mediaItemShares table', async () => { + await up({ context: { queryInterface, logger } }) + + expect(queryInterface.addColumn.calledOnce).to.be.true + expect( + queryInterface.addColumn.calledWith('mediaItemShares', 'isDownloadable', { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + ).to.be.true + + expect(logger.info.calledWith('[2.17.6 migration] UPGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Adding isDownloadable column to mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Added isDownloadable column to mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] UPGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true + }) + }) + + describe('down', () => { + it('should remove the isDownloadable column from mediaItemShares table', async () => { + queryInterface.describeTable.resolves({ isDownloadable: true }) + + await down({ context: { queryInterface, logger } }) + + expect(queryInterface.removeColumn.calledOnce).to.be.true + expect(queryInterface.removeColumn.calledWith('mediaItemShares', 'isDownloadable')).to.be.true + + expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Removing isDownloadable column from mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Removed isDownloadable column from mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true + }) + }) +})