mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 12:34:22 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1974a179e | |||
| f2357200be |
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
|
||||
// user by revoking the oldest non-expired, non-revoked tokens.
|
||||
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
|
||||
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
|
||||
// for a user among tokens that are not bound to a device auth session
|
||||
// (legacy web clients). Device sessions are independent: trimming one
|
||||
// device's token because another device logged in would silently sign the
|
||||
// first device out even though its session is still valid.
|
||||
func (r *RefreshTokenRepository) RevokeOldestActiveLegacyByUserID(ctx context.Context, userID string, limit int) error {
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
var tokens []model.RefreshToken
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (auth_session_id IS NULL OR auth_session_id = '')",
|
||||
userID, false, time.Now()).
|
||||
Order("created_at desc, id desc").
|
||||
Find(&tokens).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -163,6 +164,52 @@ func TestRefreshKeepsSessionAssociation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录:
|
||||
// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销
|
||||
// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌
|
||||
// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备
|
||||
// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。
|
||||
func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
// 默认上限是 3,这里故意登录 4 台设备。
|
||||
const devices = 4
|
||||
pairs := make([]*TokenPair, 0, devices)
|
||||
for i := 0; i < devices; i++ {
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{
|
||||
UserID: user.ID,
|
||||
DeviceID: fmt.Sprintf("device-%02d", i),
|
||||
DeviceName: fmt.Sprintf("Phone %d", i+1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create session %d: %v", i+1, err)
|
||||
}
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("issue pair %d: %v", i+1, err)
|
||||
}
|
||||
pairs = append(pairs, pair)
|
||||
}
|
||||
|
||||
for i, pair := range pairs {
|
||||
row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||
if err != nil {
|
||||
t.Fatalf("load device %d token: %v", i+1, err)
|
||||
}
|
||||
if row == nil {
|
||||
t.Fatalf("device %d refresh token missing", i+1)
|
||||
}
|
||||
if row.Revoked {
|
||||
t.Fatalf("device %d refresh token was revoked by another device", i+1)
|
||||
}
|
||||
if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil {
|
||||
t.Fatalf("device %d refresh failed: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
|
||||
@@ -184,6 +184,85 @@ func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkIgnoresLoosePosterInLibraryRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// 云下载这类库把不同影片平铺在同一个根目录里,根目录中残留的松散海报
|
||||
// 属于别的影片,不能被抓来当作当前影片的封面。
|
||||
if err := os.WriteFile(filepath.Join(root, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(root, "BBB-002.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
|
||||
t.Fatalf("library-root artwork must not be inherited: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkIgnoresLoosePosterInSharedFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "shared")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"AAA-001.mp4", "BBB-002.mp4"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(filepath.Join(dir, "BBB-002.mp4"), root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
|
||||
t.Fatalf("sibling artwork must not be inherited in a shared folder: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkKeepsLooseFallbackInItemFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "BBB-002")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "video.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(dir, "japanese-title-cover.jpg")
|
||||
fanart := filepath.Join(dir, "japanese-title-fanart.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != poster || got.BackdropURL != fanart {
|
||||
t.Fatalf("single-item folder should keep loose artwork fallback: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||
|
||||
@@ -11,23 +11,23 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
|
||||
func metadataFromArtwork(mediaPath, showBaseDir, libraryRoot string) *LocalMetadata {
|
||||
meta := &LocalMetadata{}
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
|
||||
if meta.PosterURL == "" && meta.BackdropURL == "" {
|
||||
return nil
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir, libraryRoot string) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
|
||||
meta.PosterURL = localPoster
|
||||
} else if meta.PosterURL == "" {
|
||||
} else if meta.PosterURL == "" && looseArtworkFallbackAllowed(mediaDir, libraryRoot) {
|
||||
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
|
||||
}
|
||||
dirs := []string{mediaDir, showBaseDir}
|
||||
@@ -39,7 +39,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
meta.BackdropURL = img
|
||||
break
|
||||
}
|
||||
if meta.BackdropURL == "" {
|
||||
if meta.BackdropURL == "" && looseArtworkFallbackAllowed(dir, libraryRoot) {
|
||||
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,48 @@ func firstExistingPosterImage(dir string, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// looseArtworkFallbackAllowed reports whether an unmatched image sitting in dir
|
||||
// can be attributed to mediaPath. Such an image carries no filename relation to
|
||||
// a specific video, so it is only trustworthy when the folder holds a single
|
||||
// media file and that folder is not the library root. Download tools routinely
|
||||
// drop unrelated titles flat into one directory (a 115 cloud-download library,
|
||||
// for example); claiming the first "*-poster.jpg" there made every item show the
|
||||
// same artwork. Mirrors the root-level guard findMovieNFO already applies to
|
||||
// loose NFO files.
|
||||
func looseArtworkFallbackAllowed(dir, libraryRoot string) bool {
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
if libraryRoot != "" && samePath(dir, libraryRoot) {
|
||||
return false
|
||||
}
|
||||
return countMediaFilesInDir(dir) <= 1
|
||||
}
|
||||
|
||||
// countMediaFilesInDir counts the video files directly inside dir, stopping once
|
||||
// more than one is found. It walks the real directory rather than reusing the
|
||||
// loose-image glob because a shared folder may contain no images at all.
|
||||
func countMediaFilesInDir(dir string) int {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, ok := videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))]; !ok {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > 1 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func firstAdultLooseImage(dir, kind string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
|
||||
@@ -17,12 +17,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
||||
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return metadataFromArtwork(mediaPath, ""), nil
|
||||
return metadataFromArtwork(mediaPath, "", libraryRoot), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path), libraryRoot)
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
||||
@@ -115,9 +115,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
}
|
||||
}
|
||||
if meta == nil {
|
||||
meta = metadataFromArtwork(mediaPath, showBaseDir)
|
||||
meta = metadataFromArtwork(mediaPath, showBaseDir, libraryRoot)
|
||||
} else {
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
|
||||
}
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
|
||||
@@ -170,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT
|
||||
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
|
||||
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
|
||||
// 「最大同时登录终端」只约束未绑定设备会话的旧客户端。
|
||||
//
|
||||
// 移动端每台设备有独立认证会话,刷新令牌归属该会话;如果仍按用户统一
|
||||
// 裁剪活跃令牌,第 N+1 台设备登录(或任一设备刷新)就会把最早那台设备
|
||||
// 的刷新令牌标记为已撤销,那台设备下次刷新收到 SESSION_REVOKED,被迫
|
||||
// 重新登录。设备数量由设备策略单独约束,不能靠撤销刷新令牌实现。
|
||||
if rt.AuthSessionID == "" {
|
||||
if err := s.repo.RefreshToken.RevokeOldestActiveLegacyByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
|
||||
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user