mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 06:16:37 +08:00
fix: enforce user library ACL on mounted Emby libraries (#25)
Mounted Emby libraries were always appended to web/Emby library lists and detail/play routes without checking allowed_library_ids, so restricted users could still see and open them. Filter remotes with the same visibility policy as local libraries across list/detail/series/stream and Emby Views/Items/search/playback, and label mounts in the admin ACL UI. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
@@ -94,18 +94,26 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
out = append(out, webLibraryPayload{Library: l})
|
out = append(out, webLibraryPayload{Library: l})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 远程 Emby 挂载库追加在本地库之后。
|
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
|
||||||
if svc.EmbyRemote != nil {
|
if svc.EmbyRemote != nil {
|
||||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||||
remotePayloads := make([]webLibraryPayload, len(views))
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
for i, v := range views {
|
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||||
|
for _, v := range views {
|
||||||
|
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allowedViews = append(allowedViews, v)
|
||||||
|
}
|
||||||
|
remotePayloads := make([]webLibraryPayload, len(allowedViews))
|
||||||
|
for i, v := range allowedViews {
|
||||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||||
}
|
}
|
||||||
if withPreview && len(views) > 0 {
|
if withPreview && len(allowedViews) > 0 {
|
||||||
const maxRemotePreviewWorkers = 6
|
const maxRemotePreviewWorkers = 6
|
||||||
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, v := range views {
|
for i, v := range allowedViews {
|
||||||
i, v := i, v
|
i, v := i, v
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -151,6 +159,12 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
role, _ := c.Get(middleware.CtxUserRole)
|
||||||
|
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
|
||||||
|
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, view.Library, mediaVisibilityForRequest(c, svc)) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName})
|
c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -330,6 +344,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
itemTypes := ""
|
itemTypes := ""
|
||||||
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
|
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
|
||||||
itemTypes = remoteLibraryItemTypes(view.CollectionType)
|
itemTypes = remoteLibraryItemTypes(view.CollectionType)
|
||||||
@@ -397,6 +415,10 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -579,6 +601,10 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if mount.ProxyPlay {
|
if mount.ProxyPlay {
|
||||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, mount, acct, remoteID)
|
cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, mount, acct, remoteID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
@@ -165,6 +169,10 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
|
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
@@ -207,6 +215,10 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteID)
|
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
|||||||
if mount == nil || acct == nil {
|
if mount == nil || acct == nil {
|
||||||
return emptyItemsEnvelope(p.StartIndex), nil
|
return emptyItemsEnvelope(p.StartIndex), nil
|
||||||
}
|
}
|
||||||
|
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, p.UserID), mount) {
|
||||||
|
return emptyItemsEnvelope(p.StartIndex), nil
|
||||||
|
}
|
||||||
out, err := e.remote.RemoteItems(ctx, mount, acct, p)
|
out, err := e.remote.RemoteItems(ctx, mount, acct, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -275,6 +278,9 @@ func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[
|
|||||||
if !m.Enabled {
|
if !m.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, p.UserID), &m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
acct := e.remote.AccountByID(ctx, m.AccountID)
|
acct := e.remote.AccountByID(ctx, m.AccountID)
|
||||||
if acct == nil {
|
if acct == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
|||||||
if mount == nil || acct == nil {
|
if mount == nil || acct == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||||
if err != nil || out == nil {
|
if err != nil || out == nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -109,6 +112,9 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
|||||||
if mount == nil || acct == nil {
|
if mount == nil || acct == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestViewsHidesDisallowedMountedEmbyLibraries(t *testing.T) {
|
||||||
|
svc := newTestEmbyService(t)
|
||||||
|
if err := svc.repo.DB.AutoMigrate(&model.EmbyMount{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
local := model.Library{Name: "Local", Path: "/media/local", Type: "movie", Enabled: true}
|
||||||
|
if err := svc.repo.Library.Create(t.Context(), &local); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mount := &model.EmbyMount{
|
||||||
|
AccountID: "acct-1",
|
||||||
|
RemoteViewID: "view-1",
|
||||||
|
RemoteViewName: "Remote Movies",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := svc.repo.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||||
|
|
||||||
|
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||||
|
allowed, err := json.Marshal([]string{local.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user.AllowedLibraryIDs = string(allowed)
|
||||||
|
if err := svc.repo.User.Create(t.Context(), user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a live remote service, remoteViews is empty; assert helper ACL instead
|
||||||
|
// and that local Views still honor the allow-list.
|
||||||
|
views, err := svc.Views(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Views: %v", err)
|
||||||
|
}
|
||||||
|
items := views["Items"].([]map[string]any)
|
||||||
|
for _, item := range items {
|
||||||
|
if id, _ := item["Id"].(string); id == remoteID {
|
||||||
|
t.Fatalf("disallowed remote library should not appear in Views: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !EmbyMountLibraryAllowed(MediaVisibility{AllowedLibraryIDs: []string{local.ID, remoteID}}, mount) {
|
||||||
|
t.Fatal("expected remote library allowed when listed")
|
||||||
|
}
|
||||||
|
if EmbyMountLibraryAllowed(MediaVisibility{AllowedLibraryIDs: []string{local.ID}}, mount) {
|
||||||
|
t.Fatal("expected remote library denied when not listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryIDAllowed(t *testing.T) {
|
||||||
|
if !LibraryIDAllowed(MediaVisibility{}, "any") {
|
||||||
|
t.Fatal("empty allow-list should allow all")
|
||||||
|
}
|
||||||
|
if LibraryIDAllowed(MediaVisibility{AllowedLibraryIDs: []string{"a"}}, "b") {
|
||||||
|
t.Fatal("missing id should be denied")
|
||||||
|
}
|
||||||
|
if !LibraryIDAllowed(MediaVisibility{AllowedLibraryIDs: []string{"a", "b"}}, "b") {
|
||||||
|
t.Fatal("listed id should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrEmbyRemoteNotFound
|
return nil, ErrEmbyRemoteNotFound
|
||||||
}
|
}
|
||||||
|
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||||
|
return nil, ErrEmbyRemoteNotFound
|
||||||
|
}
|
||||||
out, err := e.remote.RemotePlaybackInfo(ctx, mount, acct, remoteID, userID)
|
out, err := e.remote.RemotePlaybackInfo(ctx, mount, acct, remoteID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
|
|||||||
items = append(items, e.libraryAsView(ctx, &l))
|
items = append(items, e.libraryAsView(ctx, &l))
|
||||||
}
|
}
|
||||||
for _, remote := range e.remoteViews(ctx) {
|
for _, remote := range e.remoteViews(ctx) {
|
||||||
|
id, _ := remote["Id"].(string)
|
||||||
|
if !LibraryIDAllowed(visibility, id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, remote)
|
items = append(items, remote)
|
||||||
}
|
}
|
||||||
items = sortViewItemsByPinnedIDs(items, e.pinnedLibraryIDsForUser(ctx, userID))
|
items = sortViewItemsByPinnedIDs(items, e.pinnedLibraryIDsForUser(ctx, userID))
|
||||||
|
|||||||
@@ -131,20 +131,39 @@ func DecodeAllowedLibraryIDs(raw string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LibraryIDAllowed reports whether libraryID is permitted by the allow-list.
|
||||||
|
// An empty AllowedLibraryIDs means unrestricted access.
|
||||||
|
func LibraryIDAllowed(visibility MediaVisibility, libraryID string) bool {
|
||||||
|
if len(visibility.AllowedLibraryIDs) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range visibility.AllowedLibraryIDs {
|
||||||
|
if id == libraryID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbyMountLibraryID is the web/Emby library id for a mounted remote view.
|
||||||
|
func EmbyMountLibraryID(mount *model.EmbyMount) string {
|
||||||
|
if mount == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbyMountLibraryAllowed reports whether a mounted Emby library is allowed for
|
||||||
|
// the given visibility policy.
|
||||||
|
func EmbyMountLibraryAllowed(visibility MediaVisibility, mount *model.EmbyMount) bool {
|
||||||
|
return LibraryIDAllowed(visibility, EmbyMountLibraryID(mount))
|
||||||
|
}
|
||||||
|
|
||||||
// LibraryVisibleForUser applies profile library limits and adult-directory
|
// LibraryVisibleForUser applies profile library limits and adult-directory
|
||||||
// hiding to a library card/folder.
|
// hiding to a library card/folder.
|
||||||
func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib model.Library, visibility MediaVisibility) bool {
|
func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib model.Library, visibility MediaVisibility) bool {
|
||||||
if len(visibility.AllowedLibraryIDs) > 0 {
|
if !LibraryIDAllowed(visibility, lib.ID) {
|
||||||
found := false
|
return false
|
||||||
for _, id := range visibility.AllowedLibraryIDs {
|
|
||||||
if id == lib.ID {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if visibility.IncludeNSFW {
|
if visibility.IncludeNSFW {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -278,12 +278,19 @@ export function AdminUserLibrariesDialog({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-xs font-semibold text-ink-600">
|
<p className="truncate text-xs font-semibold text-ink-600">
|
||||||
{lib.name}
|
{lib.name}
|
||||||
|
{lib.is_remote_emby ? (
|
||||||
|
<span className="ml-1.5 rounded bg-sky-50 px-1.5 py-0.5 text-[10px] font-bold text-sky-700">
|
||||||
|
Emby 挂载
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className="truncate text-[10px] text-sand-500"
|
className="truncate text-[10px] text-sand-500"
|
||||||
title={lib.path}
|
title={lib.is_remote_emby ? lib.remote_source || lib.name : lib.path}
|
||||||
>
|
>
|
||||||
{lib.type} · {libraryDisplayPath(lib.path)}
|
{lib.is_remote_emby
|
||||||
|
? `远程 · ${lib.remote_source || 'Emby'}`
|
||||||
|
: `${lib.type} · ${libraryDisplayPath(lib.path)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export function ProfileLibraryAccessField({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{library.name}
|
{library.name}
|
||||||
|
{library.is_remote_emby ? ' · Emby' : ''}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{libraries.length === 0 && (
|
{libraries.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user