mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
381 lines
10 KiB
Go
381 lines
10 KiB
Go
package handler
|
||
|
||
import (
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/truewhile/MeBox/internal/middleware"
|
||
"github.com/truewhile/MeBox/internal/service"
|
||
)
|
||
|
||
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
|
||
func embyError(c *gin.Context, status int, msg string) {
|
||
c.JSON(status, gin.H{"Code": status, "Message": msg})
|
||
}
|
||
|
||
// embyUserID 从中间件中获取 user id。Emby auth middleware 写入 CtxUserID。
|
||
func embyUserID(c *gin.Context) string {
|
||
if uid, ok := c.Get(middleware.CtxUserID); ok {
|
||
if s, ok := uid.(string); ok {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
const embyCompatSessionTTL = 30 * time.Minute
|
||
|
||
type embyCompatSession struct {
|
||
token string
|
||
expiresAt time.Time
|
||
}
|
||
|
||
var embyCompatSessions = struct {
|
||
sync.RWMutex
|
||
items map[string]embyCompatSession
|
||
}{items: map[string]embyCompatSession{}}
|
||
|
||
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
|
||
required := middleware.EmbyAuthRequired(secret)
|
||
return func(c *gin.Context) {
|
||
// 兼容小幻影视等客户端的「UserId 直连」凭据格式:
|
||
// token 形如 X-Emby-Token=UserId="<uuid>",不是 JWT。解析出 uuid
|
||
// 注入 CtxUserID,交由后续 activeEmbyUserRequired 查库验证用户
|
||
// 存在且有效(未禁用/未过期),避免这类客户端每次 401 Invalid token。
|
||
if uid := userIdDirectToken(c); uid != "" {
|
||
c.Set(middleware.CtxUserID, uid)
|
||
c.Set(middleware.EmbyCtxUserID, uid)
|
||
c.Next()
|
||
return
|
||
}
|
||
if embyRequestToken(c) == "" {
|
||
if token := embyCompatSessionToken(c); token != "" {
|
||
c.Request.Header.Set("X-Emby-Token", token)
|
||
}
|
||
}
|
||
required(c)
|
||
}
|
||
}
|
||
|
||
// userIdDirectToken 从 Emby token 来源中识别形如 UserId="<uuid>" 的直连凭据,
|
||
// 返回其中的 uuid;不是该格式则返回空串。用于兼容小幻影视(RodelPlayer)等
|
||
// 客户端把用户 ID 当作 token 提交的行为。
|
||
// 识别三种来源:
|
||
// 1. URL query:X-Emby-Token=UserId="<uuid>"
|
||
// 2. Authorization / X-Emby-Authorization / X-MediaBrowser-Authorization 头:
|
||
// Emby UserId="<uuid>", Client="...", ...(无 Token=)
|
||
// 3. X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="<uuid>"
|
||
func userIdDirectToken(c *gin.Context) string {
|
||
if c == nil || c.Request == nil {
|
||
return ""
|
||
}
|
||
const prefix = `UserId="`
|
||
// 从一段文本中提取 UserId="<uuid>" 中的 uuid;不存在则返回空。
|
||
extract := func(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
idx := strings.Index(raw, prefix)
|
||
if idx < 0 {
|
||
return ""
|
||
}
|
||
rest := raw[idx+len(prefix):]
|
||
end := strings.Index(rest, `"`)
|
||
if end <= 0 {
|
||
return ""
|
||
}
|
||
uid := strings.TrimSpace(rest[:end])
|
||
if len(uid) > 0 && len(uid) <= 64 {
|
||
return uid
|
||
}
|
||
return ""
|
||
}
|
||
// 1) URL query 参数直传 UserId="..."。
|
||
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token", "token", "api_key", "apiKey", "ApiKey"} {
|
||
if uid := extract(c.Query(key)); uid != "" {
|
||
return uid
|
||
}
|
||
}
|
||
// 2) 认证头中的 Emby/MediaBrowser UserId="..."(无 Token= 的直连凭据)。
|
||
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
|
||
if uid := extract(c.GetHeader(header)); uid != "" {
|
||
// 仅当该头不是标准 Token= 形式时才当作直连凭据,避免误拦截。
|
||
if !strings.Contains(c.GetHeader(header), "Token=") {
|
||
return uid
|
||
}
|
||
}
|
||
}
|
||
// 3) X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="..."。
|
||
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||
if uid := extract(c.GetHeader(header)); uid != "" {
|
||
return uid
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func embyRealtimeSessionActivity(svc *service.Container) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
func recordEmbySessionActivity(c *gin.Context, svc *service.Container, userID, userName string) {
|
||
if c == nil || svc == nil || svc.Sessions == nil || strings.TrimSpace(userID) == "" {
|
||
return
|
||
}
|
||
clientInfo := embyClientInfoFromRequest(c)
|
||
svc.Sessions.RecordActivity(c.Request.Context(), userID, userName,
|
||
clientInfo.DeviceID,
|
||
clientInfo.DeviceName,
|
||
clientInfo.Client,
|
||
c.ClientIP())
|
||
}
|
||
|
||
func embyContextUserName(c *gin.Context) string {
|
||
if c == nil {
|
||
return ""
|
||
}
|
||
if value, ok := c.Get(embyCtxUserName); ok {
|
||
if username, ok := value.(string); ok {
|
||
return strings.TrimSpace(username)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func embyRememberCompatSession(c *gin.Context, token string) {
|
||
token = strings.TrimSpace(token)
|
||
if token == "" {
|
||
return
|
||
}
|
||
keys := embyCompatSessionKeys(c)
|
||
if len(keys) == 0 {
|
||
return
|
||
}
|
||
expiresAt := time.Now().Add(embyCompatSessionTTL)
|
||
embyCompatSessions.Lock()
|
||
defer embyCompatSessions.Unlock()
|
||
if len(embyCompatSessions.items) > 1000 {
|
||
now := time.Now()
|
||
for key, session := range embyCompatSessions.items {
|
||
if now.After(session.expiresAt) {
|
||
delete(embyCompatSessions.items, key)
|
||
}
|
||
}
|
||
if len(embyCompatSessions.items) > 1000 {
|
||
embyCompatSessions.items = map[string]embyCompatSession{}
|
||
}
|
||
}
|
||
for _, key := range keys {
|
||
embyCompatSessions.items[key] = embyCompatSession{token: token, expiresAt: expiresAt}
|
||
}
|
||
}
|
||
|
||
func embyCompatSessionToken(c *gin.Context) string {
|
||
keys := embyCompatSessionKeys(c)
|
||
if len(keys) == 0 {
|
||
return ""
|
||
}
|
||
now := time.Now()
|
||
embyCompatSessions.RLock()
|
||
defer embyCompatSessions.RUnlock()
|
||
for _, key := range keys {
|
||
session, ok := embyCompatSessions.items[key]
|
||
if ok && now.Before(session.expiresAt) {
|
||
return session.token
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func embyCompatSessionKeys(c *gin.Context) []string {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
ip := strings.TrimSpace(c.ClientIP())
|
||
if ip == "" {
|
||
return nil
|
||
}
|
||
keys := []string{}
|
||
add := func(kind, value string) {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
keys = append(keys, ip+"\x00"+kind+"\x00"+value)
|
||
}
|
||
}
|
||
add("device", firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"))
|
||
add("ua", c.GetHeader("User-Agent"))
|
||
return keys
|
||
}
|
||
|
||
func firstHeaderValue(c *gin.Context, names ...string) string {
|
||
for _, name := range names {
|
||
if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
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"),
|
||
c.Query("DeviceId"),
|
||
c.Query("DeviceID"),
|
||
c.Query("deviceId"),
|
||
c.Query("deviceID"),
|
||
auth["DeviceId"],
|
||
auth["DeviceID"],
|
||
),
|
||
DeviceName: firstNonEmptyHeaderString(
|
||
firstHeaderValue(c, "X-Emby-Device-Name", "X-Emby-DeviceName", "X-MediaBrowser-Device-Name", "X-MediaBrowser-DeviceName"),
|
||
c.Query("Device"),
|
||
c.Query("DeviceName"),
|
||
c.Query("device"),
|
||
c.Query("deviceName"),
|
||
auth["Device"],
|
||
),
|
||
Client: firstNonEmptyHeaderString(
|
||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||
c.Query("Client"),
|
||
c.Query("client"),
|
||
c.Query("X-Emby-Client"),
|
||
c.Query("X-MediaBrowser-Client"),
|
||
auth["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, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
|
||
return "CapyPlayer"
|
||
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
|
||
return "SenPlayer"
|
||
case strings.Contains(lower, "yamby"):
|
||
return "Yamby"
|
||
case strings.Contains(lower, "vidhub"):
|
||
return "VidHub"
|
||
case strings.Contains(lower, "fileball"):
|
||
return "Fileball"
|
||
case strings.Contains(lower, "hamhub"):
|
||
return "HamHub"
|
||
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
|
||
return "AfuseKt"
|
||
case strings.Contains(lower, "cony"):
|
||
return "Cony"
|
||
case strings.Contains(lower, "kodi"):
|
||
return "Kodi"
|
||
case strings.Contains(lower, "mrmc"):
|
||
return "MrMC"
|
||
case strings.Contains(lower, "forward"):
|
||
return "Forward"
|
||
case strings.Contains(lower, "alpha"):
|
||
return "Alpha"
|
||
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
|
||
return "DanDanPlay"
|
||
case strings.Contains(lower, "potplayer"):
|
||
return "PotPlayer"
|
||
case strings.Contains(lower, "vlc"):
|
||
return "VLC"
|
||
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") || strings.Contains(lower, "appletvos"):
|
||
return "Apple TV"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|