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

@ -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']
},

View file

@ -42,6 +42,15 @@
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p id="stream-permissions-toggle">{{ $strings.LabelPermissionsStream }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch labeledBy="stream-permissions-toggle" v-model="newUser.permissions.stream" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
@ -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',