diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 51f657db..1d7be2d9 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -436,7 +436,7 @@ export default { return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat }, showPlayButton() { - return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode) + return this.userCanStream && !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode) }, showSmallEBookIcon() { return !this.isSelectionMode && this.ebookFormat @@ -476,6 +476,9 @@ export default { userCanDownload() { return this.store.getters['user/getUserCanDownload'] }, + userCanStream() { + return this.store.getters['user/getUserCanStream'] + }, userIsAdminOrUp() { return this.store.getters['user/getIsAdminOrUp'] }, diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 6f4b7b67..8465298e 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -42,6 +42,15 @@ +
+
+

{{ $strings.LabelPermissionsStream }}

+
+
+ +
+
+

{{ $strings.LabelPermissionsUpdate }}

@@ -354,6 +363,7 @@ export default { userTypeUpdated(type) { this.newUser.permissions = { download: type !== 'guest', + stream: true, update: type === 'admin', delete: type === 'admin', upload: type === 'admin', diff --git a/client/cypress/tests/components/cards/LazyBookCard.cy.js b/client/cypress/tests/components/cards/LazyBookCard.cy.js index ab685b0d..352691dc 100644 --- a/client/cypress/tests/components/cards/LazyBookCard.cy.js +++ b/client/cypress/tests/components/cards/LazyBookCard.cy.js @@ -42,6 +42,7 @@ function createMountOptions() { 'user/getUserCanUpdate': true, 'user/getUserCanDelete': true, 'user/getUserCanDownload': true, + 'user/getUserCanStream': true, 'user/getIsAdminOrUp': true, 'user/getUserMediaProgress': (id) => null, 'user/getUserSetting': (settingName) => false, @@ -163,6 +164,15 @@ describe('LazyBookCard', () => { cy.get('&ebookFormat').should('not.exist') }) + it('hides play button on mouseover when user cannot stream', () => { + mountOptions.mocks.$store.getters['user/getUserCanStream'] = false + cy.mount(LazyBookCard, mountOptions) + cy.get('#book-card-0').trigger('mouseover') + + cy.get('&overlay').should('be.visible') + cy.get('&playButton').should('be.hidden') + }) + it('routes to item page when clicked', () => { mountOptions.mocks.$router = { push: cy.stub().as('routerPush') } cy.mount(LazyBookCard, mountOptions) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1d8f0f20..d2ce3bee 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -86,6 +86,15 @@ {{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} + + download + {{ $strings.LabelDownload }} + + +

+ {{ $strings.MessageNoStreamOrDownloadAccess }} +

+ error {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} @@ -229,6 +238,7 @@ export default { return !!this.mediaMetadata.abridged }, showPlayButton() { + if (!this.userCanStream) return false if (this.isMissing || this.isInvalid) return false if (this.isPodcast) return this.podcastEpisodes.length return this.tracks.length @@ -352,6 +362,9 @@ export default { userCanDownload() { return this.$store.getters['user/getUserCanDownload'] }, + userCanStream() { + return this.$store.getters['user/getUserCanStream'] + }, showRssFeedBtn() { if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks diff --git a/client/store/user.js b/client/store/user.js index 96e79d12..279111c4 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -53,6 +53,9 @@ export const getters = { getUserCanDownload: (state) => { return !!state.user?.permissions?.download }, + getUserCanStream: (state) => { + return state.user?.permissions?.stream !== false + }, getUserCanUpload: (state) => { return !!state.user?.permissions?.upload }, diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb28..34ccc26a 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -512,6 +512,7 @@ "LabelPermissionsCreateEreader": "Can Create Ereader", "LabelPermissionsDelete": "Can Delete", "LabelPermissionsDownload": "Can Download", + "LabelPermissionsStream": "Can Stream", "LabelPermissionsUpdate": "Can Update", "LabelPermissionsUpload": "Can Upload", "LabelPersonalYearReview": "Your Year in Review ({0})", @@ -851,6 +852,7 @@ "MessageNoFoldersAvailable": "No Folders Available", "MessageNoGenres": "No Genres", "MessageNoIssues": "No Issues", + "MessageNoStreamOrDownloadAccess": "Contact your server admin for access", "MessageNoItems": "No Items", "MessageNoItemsFound": "No items found", "MessageNoListeningSessions": "No Listening Sessions", diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5f7bd973..caf73d4c 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -413,6 +413,10 @@ class LibraryItemController { * @param {Response} res */ startPlaybackSession(req, res) { + if (!req.user.canStream) { + Logger.warn(`User "${req.user.username}" attempted to stream without permission`) + return res.sendStatus(403) + } if (!req.libraryItem.hasAudioTracks) { Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) return res.sendStatus(404) @@ -430,6 +434,10 @@ class LibraryItemController { * @param {Response} res */ startEpisodePlaybackSession(req, res) { + if (!req.user.canStream) { + Logger.warn(`User "${req.user.username}" attempted to stream without permission`) + return res.sendStatus(403) + } if (!req.libraryItem.isPodcast) { Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`) return res.sendStatus(400) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 7c61611d..c3080599 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -6,6 +6,7 @@ const Logger = require('../Logger') /** * @typedef {Object} ApiKeyPermissions * @property {boolean} download + * @property {boolean} stream * @property {boolean} update * @property {boolean} delete * @property {boolean} upload @@ -84,6 +85,7 @@ class ApiKey extends Model { static getDefaultPermissions() { return { download: true, + stream: true, update: true, delete: true, upload: true, diff --git a/server/models/User.js b/server/models/User.js index 936efde1..c281fa3e 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -125,6 +125,7 @@ class User extends Model { */ static permissionMapping = { canDownload: 'download', + canStream: 'stream', canUpload: 'upload', canDelete: 'delete', canUpdate: 'update', @@ -169,6 +170,7 @@ class User extends Model { static getDefaultPermissionsForUserType(type) { return { download: true, + stream: true, update: type === 'root' || type === 'admin', delete: type === 'root', upload: type === 'root' || type === 'admin', @@ -567,6 +569,9 @@ class User extends Model { get canDownload() { return !!this.permissions?.download && this.isActive } + get canStream() { + return (this.permissions?.stream !== false) && this.isActive + } get canUpload() { return !!this.permissions?.upload && this.isActive } diff --git a/test/server/controllers/LibraryItemController.canStream.test.js b/test/server/controllers/LibraryItemController.canStream.test.js new file mode 100644 index 00000000..d3c1e5de --- /dev/null +++ b/test/server/controllers/LibraryItemController.canStream.test.js @@ -0,0 +1,101 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const LibraryItemController = require('../../../server/controllers/LibraryItemController') +const Logger = require('../../../server/Logger') + +describe('LibraryItemController - canStream enforcement', () => { + beforeEach(() => { + sinon.stub(Logger, 'warn') + sinon.stub(Logger, 'error') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('startPlaybackSession', () => { + it('should return 403 when user cannot stream', () => { + const req = { + user: { canStream: false, username: 'testuser' }, + libraryItem: { hasAudioTracks: true, id: 'li_test' } + } + const res = { sendStatus: sinon.spy() } + + LibraryItemController.startPlaybackSession.call({}, req, res) + + expect(res.sendStatus.calledWith(403)).to.be.true + expect(Logger.warn.calledOnce).to.be.true + expect(Logger.warn.firstCall.args[0]).to.include('testuser') + }) + + it('should not block when user can stream', () => { + const startSessionRequest = sinon.spy() + const req = { + user: { canStream: true }, + libraryItem: { hasAudioTracks: true, id: 'li_test' } + } + const res = { sendStatus: sinon.spy() } + + LibraryItemController.startPlaybackSession.call( + { playbackSessionManager: { startSessionRequest } }, + req, + res + ) + + expect(res.sendStatus.called).to.be.false + expect(startSessionRequest.calledOnce).to.be.true + }) + + it('should return 404 when item has no audio tracks', () => { + const req = { + user: { canStream: true }, + libraryItem: { hasAudioTracks: false, id: 'li_test' } + } + const res = { sendStatus: sinon.spy() } + + LibraryItemController.startPlaybackSession.call({}, req, res) + + expect(res.sendStatus.calledWith(404)).to.be.true + }) + }) + + describe('startEpisodePlaybackSession', () => { + it('should return 403 when user cannot stream', () => { + const req = { + user: { canStream: false, username: 'testuser' }, + libraryItem: { isPodcast: true, id: 'li_test' }, + params: { episodeId: 'ep_1' } + } + const res = { sendStatus: sinon.spy() } + + LibraryItemController.startEpisodePlaybackSession.call({}, req, res) + + expect(res.sendStatus.calledWith(403)).to.be.true + expect(Logger.warn.calledOnce).to.be.true + }) + + it('should not block when user can stream', () => { + const startSessionRequest = sinon.spy() + const req = { + user: { canStream: true }, + libraryItem: { + isPodcast: true, + id: 'li_test', + media: { podcastEpisodes: [{ id: 'ep_1' }] } + }, + params: { episodeId: 'ep_1' } + } + const res = { sendStatus: sinon.spy() } + + LibraryItemController.startEpisodePlaybackSession.call( + { playbackSessionManager: { startSessionRequest } }, + req, + res + ) + + expect(res.sendStatus.called).to.be.false + expect(startSessionRequest.calledOnce).to.be.true + }) + }) +}) diff --git a/test/server/models/User.canStream.test.js b/test/server/models/User.canStream.test.js new file mode 100644 index 00000000..e659e9cb --- /dev/null +++ b/test/server/models/User.canStream.test.js @@ -0,0 +1,78 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') + +const Database = require('../../../server/Database') + +describe('User - canStream permission', () => { + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + }) + + afterEach(async () => { + await Database.sequelize.sync({ force: true }) + }) + + describe('getDefaultPermissionsForUserType', () => { + it('should default stream to true for all user types', () => { + for (const type of ['root', 'admin', 'user', 'guest']) { + const permissions = Database.userModel.getDefaultPermissionsForUserType(type) + expect(permissions.stream).to.equal(true, `stream should default to true for type "${type}"`) + } + }) + }) + + describe('canStream getter', () => { + it('should return true when stream permission is true and user is active', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'hashed', + type: 'user', + isActive: true, + permissions: { stream: true, download: true } + }) + expect(user.canStream).to.be.true + }) + + it('should return false when stream permission is explicitly false', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'hashed', + type: 'user', + isActive: true, + permissions: { stream: false, download: true } + }) + expect(user.canStream).to.be.false + }) + + it('should return true when stream permission is missing (migration safety)', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'hashed', + type: 'user', + isActive: true, + permissions: { download: true } + }) + expect(user.canStream).to.be.true + }) + + it('should return false when user is inactive even with stream permission', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'hashed', + type: 'user', + isActive: false, + permissions: { stream: true } + }) + expect(user.canStream).to.be.false + }) + }) + + describe('permissionMapping', () => { + it('should map canStream to stream', () => { + expect(Database.userModel.permissionMapping.canStream).to.equal('stream') + }) + }) +})