Files
silo-server/internal/access/groups.go
28c6ddc237 feat(playback): add per-user transcoding controls (#375)
* feat(playback): add per-user transcoding controls

* fix(playback): enforce forced video transcode permission

* chore: address transcode control review feedback

* fix(playback): recheck transcode permission on audio switch

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-10 22:30:04 -04:00

157 lines
4.6 KiB
Go

package access
import (
"context"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
// GroupPolicyProvider loads the access-group restriction layer for a user.
type GroupPolicyProvider interface {
GetPolicyForUser(ctx context.Context, userID int) (*GroupPolicy, error)
}
// GroupPolicy is the restriction layer contributed by a user's access group.
type GroupPolicy struct {
ID int64
LibraryIDs []int // nil = unrestricted
MaxPlaybackQuality string
DownloadAllowed bool
DownloadTranscodeAllowed bool
MaxStreams int // 0 = no group cap
MaxTranscodes int
AllowedPermissions []string // nil = all assignable
RequestsAllowed bool
}
// EffectiveUserPolicy is the account layer after the group restriction is applied.
type EffectiveUserPolicy struct {
LibraryIDs []int // nil = unrestricted
MaxPlaybackQuality string
DownloadAllowed bool
DownloadTranscodeAllowed bool
TranscodeAllowed bool
AudioTranscodeAllowed bool
MaxStreams int
MaxTranscodes int
Permissions []string
RequestsAllowed bool
}
// EffectivePolicyForUser loads a user's group policy and returns the merged
// restriction layer. Nil providers are treated as "no group".
func EffectivePolicyForUser(ctx context.Context, user *models.User, provider GroupPolicyProvider) (EffectiveUserPolicy, error) {
if provider == nil || user == nil {
return ApplyGroupPolicy(user, nil), nil
}
group, err := provider.GetPolicyForUser(ctx, user.ID)
if err != nil {
return EffectiveUserPolicy{}, err
}
return ApplyGroupPolicy(user, group), nil
}
// ApplyGroupPolicy restricts user account policy by the optional access group.
func ApplyGroupPolicy(user *models.User, group *GroupPolicy) EffectiveUserPolicy {
if user == nil {
return EffectiveUserPolicy{RequestsAllowed: true}
}
effective := EffectiveUserPolicy{
LibraryIDs: cloneInts(user.LibraryIDs),
MaxPlaybackQuality: user.MaxPlaybackQuality,
DownloadAllowed: user.DownloadAllowed,
DownloadTranscodeAllowed: user.DownloadTranscodeAllowed,
TranscodeAllowed: user.TranscodeAllowed,
AudioTranscodeAllowed: user.AudioTranscodeAllowed,
MaxStreams: user.MaxStreams,
MaxTranscodes: user.MaxTranscodes,
Permissions: cloneStrings(user.Permissions),
RequestsAllowed: true,
}
if group == nil {
return effective
}
effective.LibraryIDs = restrictLibraryIDs(user.LibraryIDs, group.LibraryIDs)
effective.MaxPlaybackQuality = MinQuality(user.MaxPlaybackQuality, group.MaxPlaybackQuality)
effective.DownloadAllowed = user.DownloadAllowed && group.DownloadAllowed
effective.DownloadTranscodeAllowed = user.DownloadTranscodeAllowed && group.DownloadTranscodeAllowed
effective.MaxStreams = strictestPositive(user.MaxStreams, group.MaxStreams)
effective.MaxTranscodes = strictestPositive(user.MaxTranscodes, group.MaxTranscodes)
if group.AllowedPermissions != nil {
effective.Permissions = intersectStrings(user.Permissions, group.AllowedPermissions)
}
effective.RequestsAllowed = group.RequestsAllowed
return effective
}
func restrictLibraryIDs(userLibraryIDs, groupLibraryIDs []int) []int {
switch {
case userLibraryIDs != nil && groupLibraryIDs != nil:
return intersectInts(userLibraryIDs, groupLibraryIDs)
case userLibraryIDs != nil:
return cloneInts(userLibraryIDs)
case groupLibraryIDs != nil:
return sortedUniqueInts(groupLibraryIDs)
default:
return nil
}
}
func strictestPositive(userValue, groupValue int) int {
switch {
case userValue <= 0:
return groupValue
case groupValue <= 0:
return userValue
case userValue <= groupValue:
return userValue
default:
return groupValue
}
}
func intersectStrings(left, right []string) []string {
if len(left) == 0 || len(right) == 0 {
return []string{}
}
allowed := make(map[string]struct{}, len(right))
for _, raw := range right {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
allowed[value] = struct{}{}
}
seen := make(map[string]struct{}, len(left))
out := make([]string, 0, len(left))
for _, raw := range left {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if _, ok := allowed[value]; !ok {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}
func cloneStrings(values []string) []string {
if values == nil {
return nil
}
out := make([]string, len(values))
copy(out, values)
return out
}