mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb92373f0d | |||
| 2b99f5f108 |
@@ -94,18 +94,26 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
out = append(out, webLibraryPayload{Library: l})
|
||||
}
|
||||
}
|
||||
// 远程 Emby 挂载库追加在本地库之后。
|
||||
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
|
||||
if svc.EmbyRemote != nil {
|
||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||
remotePayloads := make([]webLibraryPayload, len(views))
|
||||
for i, v := range views {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
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}
|
||||
}
|
||||
if withPreview && len(views) > 0 {
|
||||
if withPreview && len(allowedViews) > 0 {
|
||||
const maxRemotePreviewWorkers = 6
|
||||
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
||||
var wg sync.WaitGroup
|
||||
for i, v := range views {
|
||||
for i, v := range allowedViews {
|
||||
i, v := i, v
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -151,6 +159,12 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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})
|
||||
return
|
||||
}
|
||||
@@ -330,6 +344,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
itemTypes := ""
|
||||
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
|
||||
itemTypes = remoteLibraryItemTypes(view.CollectionType)
|
||||
@@ -397,6 +415,10 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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)
|
||||
if err != nil {
|
||||
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"})
|
||||
return
|
||||
}
|
||||
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if mount.ProxyPlay {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
|
||||
@@ -74,6 +74,10 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
@@ -165,6 +169,10 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
@@ -207,6 +215,10 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
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)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
|
||||
@@ -156,6 +156,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if mount == nil || acct == 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -275,6 +278,9 @@ func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, p.UserID), &m) {
|
||||
continue
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, m.AccountID)
|
||||
if acct == nil {
|
||||
continue
|
||||
|
||||
@@ -21,6 +21,9 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if mount == nil || acct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || out == nil {
|
||||
return out, err
|
||||
@@ -109,6 +112,9 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
if mount == nil || acct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, ErrEmbyRemoteNotFound
|
||||
}
|
||||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return nil, ErrEmbyRemoteNotFound
|
||||
}
|
||||
out, err := e.remote.RemotePlaybackInfo(ctx, mount, acct, remoteID, userID)
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
for _, remote := range e.remoteViews(ctx) {
|
||||
id, _ := remote["Id"].(string)
|
||||
if !LibraryIDAllowed(visibility, id) {
|
||||
continue
|
||||
}
|
||||
items = append(items, remote)
|
||||
}
|
||||
items = sortViewItemsByPinnedIDs(items, e.pinnedLibraryIDsForUser(ctx, userID))
|
||||
|
||||
@@ -131,20 +131,39 @@ func DecodeAllowedLibraryIDs(raw string) []string {
|
||||
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
|
||||
// hiding to a library card/folder.
|
||||
func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib model.Library, visibility MediaVisibility) bool {
|
||||
if len(visibility.AllowedLibraryIDs) > 0 {
|
||||
found := false
|
||||
for _, id := range visibility.AllowedLibraryIDs {
|
||||
if id == lib.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
if !LibraryIDAllowed(visibility, lib.ID) {
|
||||
return false
|
||||
}
|
||||
if visibility.IncludeNSFW {
|
||||
return true
|
||||
|
||||
@@ -278,12 +278,19 @@ export function AdminUserLibrariesDialog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-ink-600">
|
||||
{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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,6 +155,7 @@ export function ProfileLibraryAccessField({
|
||||
}
|
||||
>
|
||||
{library.name}
|
||||
{library.is_remote_emby ? ' · Emby' : ''}
|
||||
</button>
|
||||
))}
|
||||
{libraries.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user