Add canStream user permission to control streaming access

Adds a per-user "Can Stream" permission mirroring the existing "Can Download"
pattern. Server admins can now disable streaming for specific users, encouraging
local downloads instead. Addresses #2572.

Changes:
- User model: stream permission in mapping, defaults, and getter
- ApiKey model: stream permission in defaults
- Controller: 403 enforcement on playback session creation endpoints
- Frontend: permission toggle in admin UI, play button gated by canStream,
  download button shown when streaming disabled, message when neither allowed
- Tests: 11 Mocha tests (model + controller), 1 Cypress test (card UI)
- Localization: English strings for toggle label and fallback message

The getter uses !== false (rather than !!) so existing users without the
stream key in their permissions JSON default to allowed on upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zriddle 2026-04-02 14:12:08 -06:00
parent 64cbf59609
commit 464b720d9e
11 changed files with 236 additions and 1 deletions

View file

@ -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')
})
})
})