mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
Harden cleanup and client compatibility
This commit is contained in:
+144
-21
@@ -138,6 +138,123 @@ func firstHeaderValue(c *gin.Context, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type embyClientInfo struct {
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
Client string
|
||||
}
|
||||
|
||||
func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
|
||||
auth := parseMediaBrowserAuthorization(firstHeaderValue(c,
|
||||
"X-Emby-Authorization",
|
||||
"X-MediaBrowser-Authorization",
|
||||
"Authorization",
|
||||
))
|
||||
info := embyClientInfo{
|
||||
DeviceID: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"),
|
||||
auth["DeviceId"],
|
||||
auth["DeviceID"],
|
||||
),
|
||||
DeviceName: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Device-Name", "X-Emby-DeviceName", "X-MediaBrowser-Device-Name", "X-MediaBrowser-DeviceName"),
|
||||
auth["Device"],
|
||||
),
|
||||
Client: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||||
auth["Client"],
|
||||
),
|
||||
}
|
||||
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
if info.Client == "" {
|
||||
info.Client = embyClientFromUserAgent(ua)
|
||||
}
|
||||
if info.DeviceName == "" {
|
||||
info.DeviceName = embyDeviceFromUserAgent(ua)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func parseMediaBrowserAuthorization(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return out
|
||||
}
|
||||
for _, prefix := range []string{"MediaBrowser ", "Emby "} {
|
||||
if strings.HasPrefix(raw, prefix) {
|
||||
raw = strings.TrimSpace(strings.TrimPrefix(raw, prefix))
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||
if key != "" && value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyHeaderString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyClientFromUserAgent(ua string) string {
|
||||
ua = strings.TrimSpace(ua)
|
||||
lower := strings.ToLower(ua)
|
||||
switch {
|
||||
case strings.Contains(lower, "infuse"):
|
||||
return "Infuse"
|
||||
case strings.Contains(lower, "emby"):
|
||||
return "Emby"
|
||||
case strings.Contains(lower, "jellyfin"):
|
||||
return "Jellyfin"
|
||||
case strings.Contains(lower, "yamby"):
|
||||
return "Yamby"
|
||||
case strings.Contains(lower, "vidhub"):
|
||||
return "VidHub"
|
||||
case strings.Contains(lower, "hills"):
|
||||
return "Hills"
|
||||
default:
|
||||
return ua
|
||||
}
|
||||
}
|
||||
|
||||
func embyDeviceFromUserAgent(ua string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(ua))
|
||||
switch {
|
||||
case strings.Contains(lower, "android"):
|
||||
return "Android"
|
||||
case strings.Contains(lower, "iphone"):
|
||||
return "iPhone"
|
||||
case strings.Contains(lower, "ipad"):
|
||||
return "iPad"
|
||||
case strings.Contains(lower, "ios"):
|
||||
return "iOS"
|
||||
case strings.Contains(lower, "windows"):
|
||||
return "Windows PC"
|
||||
case strings.Contains(lower, "macintosh") || strings.Contains(lower, "mac os"):
|
||||
return "Mac"
|
||||
case strings.Contains(lower, "linux"):
|
||||
return "Linux PC"
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
|
||||
return "Apple TV"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -421,11 +538,12 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Device != nil {
|
||||
svc.Device.RecordLogin(c.Request.Context(), resp.User.ID,
|
||||
c.GetHeader("X-Emby-Device-Id"),
|
||||
c.GetHeader("X-Emby-Device-Name"),
|
||||
c.GetHeader("X-Emby-Client"),
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP())
|
||||
}
|
||||
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
|
||||
@@ -446,9 +564,9 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"Id": resp.User.ID,
|
||||
"UserId": resp.User.ID,
|
||||
"UserName": resp.User.Username,
|
||||
"Client": c.GetHeader("X-Emby-Client"),
|
||||
"DeviceId": c.GetHeader("X-Emby-Device-Id"),
|
||||
"DeviceName": c.GetHeader("X-Emby-Device-Name"),
|
||||
"Client": clientInfo.Client,
|
||||
"DeviceId": clientInfo.DeviceID,
|
||||
"DeviceName": clientInfo.DeviceName,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -786,16 +904,16 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
|
||||
// ─── Images ──────────────────────────────────────────────────────────────────
|
||||
|
||||
var embyTransparentPNG = []byte{
|
||||
var embyPlaceholderPNG = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x50, 0xd1, 0x30, 0xf8,
|
||||
0x0f, 0x00, 0x02, 0x6c, 0x01, 0x7c, 0x30, 0xed,
|
||||
0x6e, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。
|
||||
@@ -810,7 +928,7 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServeTransparentImage(c)
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if typ, ref, ok := parseCloudPlayImageURL(raw); ok {
|
||||
@@ -819,24 +937,24 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServeTransparentImage(c)
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServeTransparentImage(c)
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyServeTransparentImage(c *gin.Context) {
|
||||
func embyServePlaceholderImage(c *gin.Context) {
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyTransparentPNG)))
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/png", embyTransparentPNG)
|
||||
c.Data(http.StatusOK, "image/png", embyPlaceholderPNG)
|
||||
}
|
||||
|
||||
func parseCloudPlayImageURL(raw string) (string, string, bool) {
|
||||
@@ -1195,7 +1313,8 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
// 被「一键踢下线」的设备拒绝继续播放,直到重新登录。
|
||||
if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, c.GetHeader("X-Emby-Device-Id")) {
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, clientInfo.DeviceID) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -1203,9 +1322,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 标记该设备正在播放并执行并发播放防共享检测。
|
||||
if svc.Device != nil {
|
||||
svc.Device.RecordPlayback(c.Request.Context(), uid,
|
||||
c.GetHeader("X-Emby-Device-Id"),
|
||||
c.GetHeader("X-Emby-Device-Name"),
|
||||
c.GetHeader("X-Emby-Client"))
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -1251,6 +1370,10 @@ func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if played && svc.Device != nil {
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
svc.Device.RecordPlayback(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client)
|
||||
}
|
||||
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
|
||||
if out != nil {
|
||||
c.JSON(http.StatusOK, out["UserData"])
|
||||
|
||||
@@ -116,6 +116,122 @@ func TestEmbyAuthenticateByNameAcceptsCaseVariantUsernameAndPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", DeviceId="device-42"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
user, err := repos.User.FindByUsername(context.Background(), "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("find user: %v", err)
|
||||
}
|
||||
devices, err := repos.UserDevice.ListByUser(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("devices = %#v, want one recorded device", devices)
|
||||
}
|
||||
if devices[0].DeviceID != "device-42" || devices[0].DeviceName != "PC" || devices[0].Client != "Infuse" {
|
||||
t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Watched Movie",
|
||||
Path: `/media/movies/Watched Movie.mkv`,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Device: service.NewDeviceService(zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
token := signedTestToken(t, secret)
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/user-1/PlayedItems/media-1", nil)
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="played-device", Token="`+token+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
devices, err := repos.UserDevice.ListByUser(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if len(devices) != 1 || devices[0].LastPlayAt == nil {
|
||||
t.Fatalf("mark played should refresh playback device, got %#v", devices)
|
||||
}
|
||||
if devices[0].DeviceID != "played-device" || devices[0].DeviceName != "iPhone" || devices[0].Client != "Infuse" {
|
||||
t.Fatalf("playback device info not parsed: %#v", devices[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyCompatSessionAllowsSameClientRequestsWithoutToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
Reference in New Issue
Block a user